Sync broccolini-bot: rename from zammad, docs in docs/, security gitignore, remove zammad deps

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
samkintop
2026-02-12 02:56:00 -06:00
parent 08a16b4a75
commit 29a13768f7
37 changed files with 1093 additions and 3229 deletions

View File

@@ -11,10 +11,9 @@ const {
PermissionFlagsBits
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG, ZAMMAD, TICKET_TAGS } = require('../config');
const { CONFIG, TICKET_TAGS } = require('../config');
const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils');
const { canRename, makeTicketName, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { closeZammadTicket, ensureZammadUserForDiscordUser } = require('../services/zammad');
const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings');
@@ -26,6 +25,36 @@ const Ticket = mongoose.model('Ticket');
const Tag = mongoose.model('Tag');
const User = mongoose.model('User');
/**
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
* Used to restrict commands to staff only; customers cannot use bot commands.
* @param {import('discord.js').GuildMember|null} member
* @returns {boolean}
*/
function hasStaffRole(member) {
if (!member?.roles?.cache) return false;
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
return additional.some(roleId => member.roles.cache.has(roleId));
}
/**
* Reply ephemeral and return true if the interaction is in a guild and the user is not staff (so caller should return).
* @param {import('discord.js').CommandInteraction|import('discord.js').ContextMenuCommandInteraction} interaction
* @returns {Promise<boolean>} true if caller should return (user is not allowed)
*/
async function requireStaffRole(interaction) {
if (!interaction.guild) return false;
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
if (hasStaffRole(interaction.member)) return false;
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
await interaction.reply({
content: `This command is only available to the support team (${roleMention}).`,
ephemeral: true
});
return true;
}
/**
* Run escalation to a target DB tier (1 = tier 2, 2 = tier 3). Caller must validate ticket and currentTier < nextTier.
*/
@@ -69,7 +98,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
const pendingEmbed = new EmbedBuilder()
.setDescription('Ticket will be escalated in a few seconds.')
.setColor(0xe74c3c);
.setColor(CONFIG.EMBED_COLOR_INFO);
await interaction.reply({ content: null, embeds: [pendingEmbed] });
const creatorId = isDiscordTicket
@@ -84,12 +113,12 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
`${heyLine}\n**Getting the senior ${roleMention} for you.**`
);
const seniorLine = `A senior ${CONFIG.SUPPORT_NAME} will be here to assist as soon as possible.`;
const escalationBody = CONFIG.ESCALATION_MESSAGE
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME)
+ (reason ? `\n\n**Reason:** ${reason}` : '');
const escalatedEmbed = new EmbedBuilder()
.setDescription(
`${CONFIG.ESCALATION_MESSAGE}\n\n${seniorLine}${reason ? `\n\n**Reason:** ${reason}` : ''}`
)
.setColor(0x2ecc71);
.setDescription(escalationBody)
.setColor(CONFIG.EMBED_COLOR_INFO);
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
const escalationRow = getTicketActionRow(updatedTicketForRow);
await interaction.channel.send({
@@ -99,7 +128,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
});
if (!isDiscordTicket && ticket.gmailThreadId) {
const emailBody = `${CONFIG.ESCALATION_MESSAGE}\n\n${seniorLine}${reason ? `\n\nReason: ${reason}` : ''}`;
const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (reason ? `\n\nReason: ${reason}` : '');
await sendTicketNotificationEmail(
ticket,
`Ticket escalated to ${nextTier === 1 ? 'tier 2' : 'tier 3'}`,
@@ -199,6 +228,9 @@ async function runDeescalation(interaction, ticket) {
* Main slash-command handler.
*/
async function handleCommand(interaction) {
// Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES)
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
// /setup
if (interaction.commandName === 'setup') {
return handleSetupCommand(interaction);
@@ -415,8 +447,9 @@ async function handleCommand(interaction) {
await interaction.reply('Ticket force-closed. Archiving...');
// Generate transcript inline (same as confirm_close)
try {
await interaction.channel.send(CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await interaction.channel.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
@@ -434,8 +467,25 @@ async function handleCommand(interaction) {
.catch(() => null);
if (transcriptChan) {
const closedAt = new Date();
const openedStr = new Date(ticket.createdAt).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const closedStr = closedAt.toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, interaction.channel.name)
.replace(/\{email\}/g, ticket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await transcriptChan.send({
content: `Force-closed transcript: \`${ticket.senderEmail}\``,
content: transcriptContent,
files: [file]
});
}
@@ -443,10 +493,6 @@ async function handleCommand(interaction) {
console.error('Transcript error (force-close):', tErr);
}
if (ticket.zammadTicketId && ZAMMAD.URL && ZAMMAD.TOKEN) {
await closeZammadTicket(ticket.zammadTicketId);
}
setTimeout(async () => {
try {
await interaction.channel.delete('Ticket force-closed');
@@ -567,9 +613,11 @@ async function handleCommand(interaction) {
else if (subcommand === 'delete') {
const name = interaction.options.getString('name');
// Use :: delimiter so tag names with underscores are parsed correctly (Discord customId max 100 chars)
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
const confirmRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(`confirm_delete_tag_${name}`)
.setCustomId(customId)
.setLabel('Yes, Delete Tag')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
@@ -726,12 +774,12 @@ async function handleCommand(interaction) {
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket (thread)')
.setStyle(ButtonStyle.Success)
.setStyle(ButtonStyle.Secondary)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket (channel)')
.setStyle(ButtonStyle.Success)
.setStyle(ButtonStyle.Secondary)
.setEmoji('📁')
);
} else if (panelType === 'thread') {
@@ -739,7 +787,7 @@ async function handleCommand(interaction) {
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Success)
.setStyle(ButtonStyle.Secondary)
.setEmoji('🧵')
);
} else if (panelType === 'category') {
@@ -747,7 +795,7 @@ async function handleCommand(interaction) {
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Success)
.setStyle(ButtonStyle.Secondary)
.setEmoji('📁')
);
} else {
@@ -755,7 +803,7 @@ async function handleCommand(interaction) {
new ButtonBuilder()
.setCustomId('open_ticket')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Success)
.setStyle(ButtonStyle.Secondary)
.setEmoji('✅')
);
}
@@ -925,6 +973,9 @@ async function handleCommand(interaction) {
* Context menu interaction handler.
*/
async function handleContextMenu(interaction) {
// Restrict all guild context menus to staff role only
if (await requireStaffRole(interaction)) return;
// Create Ticket From Message
if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') {
trackInteraction('contextMenus', 'create-ticket-from-message', interaction.user.tag);
@@ -991,20 +1042,9 @@ async function handleContextMenu(interaction) {
lastActivity: now
});
try {
const websiteUser = await User.findOne({ discordID: String(message.author.id) })
.select('email discordID firstname lastname')
.lean();
if (websiteUser?.email) {
await ensureZammadUserForDiscordUser(websiteUser);
}
} catch (zErr) {
console.error('Zammad user ensure (Discord ticket from message) failed:', zErr.message);
}
const welcomeEmbed = new EmbedBuilder()
.setDescription("We got your ticket. We'll be with you as soon as possible.\nFeel free to add any additional information to your ticket.")
.setColor(0x2ecc71);
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
.setColor(CONFIG.EMBED_COLOR_INFO);
const infoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)