more mvp strip
This commit is contained in:
@@ -13,8 +13,6 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs
|
||||
# Ticket creation: set one or both; /panel and /email-routing choose behavior
|
||||
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
|
||||
TICKET_CATEGORY_ID= # Category for email-originated ticket channels
|
||||
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
||||
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
||||
|
||||
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
|
||||
TICKET_CATEGORY_NAME=Open Tickets
|
||||
@@ -137,10 +135,6 @@ SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (
|
||||
INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally)
|
||||
INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string)
|
||||
|
||||
# --- Thread-style tickets (legacy) ---
|
||||
USE_THREADS=false
|
||||
THREAD_PARENT_CHANNEL=
|
||||
|
||||
# --- Game list (comma-separated; used for detection and tags) ---
|
||||
GAME_LIST=Project Zomboid, Minecraft, ...
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ const { mongoose } = require('./db-connection');
|
||||
// Handlers
|
||||
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
||||
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
||||
const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup');
|
||||
const { handleDiscordReply } = require('./handlers/messages');
|
||||
|
||||
// Services & jobs
|
||||
@@ -113,30 +112,10 @@ async function runHandler(name, interaction, fn) {
|
||||
}
|
||||
|
||||
client.on('interactionCreate', async interaction => {
|
||||
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
|
||||
try {
|
||||
const handled = await handleSetupButton(interaction);
|
||||
if (handled) return;
|
||||
} catch (err) {
|
||||
console.error('Setup button error:', err);
|
||||
logError('handleSetupButton', err, null, client).catch(() => {});
|
||||
await interaction.reply({
|
||||
content: `Setup error: ${err.message}. Try \`/setup\` again.`,
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
return runHandler('handleButton', interaction, () => handleButton(interaction));
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) {
|
||||
const handled = await runHandler('handleSetupModal', interaction, () => handleSetupModal(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
|
||||
// Handle signature modal submit
|
||||
try {
|
||||
@@ -176,11 +155,6 @@ client.on('interactionCreate', async interaction => {
|
||||
return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction));
|
||||
}
|
||||
|
||||
if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) {
|
||||
const handled = await runHandler('handleSetupSelect', interaction, () => handleSetupSelect(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
|
||||
}
|
||||
|
||||
@@ -205,13 +205,6 @@ async function registerCommands() {
|
||||
InteractionContextType.PrivateChannel
|
||||
]),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('setup')
|
||||
.setDescription('Run the panel setup wizard (name, support role, category, transcript channel, panel channel)')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('panel')
|
||||
.setDescription('Create a ticket panel for users to open Discord tickets')
|
||||
@@ -253,13 +246,6 @@ async function registerCommands() {
|
||||
.setRequired(false)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('email-routing')
|
||||
.setDescription('Switch where new email tickets are created: threads or category channels')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('notifydm')
|
||||
.setDescription('Toggle DM notifications when your ticket receives a customer reply.')
|
||||
|
||||
@@ -53,12 +53,12 @@ const CONFIG = {
|
||||
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
|
||||
LOGO_URL: process.env.LOGO_URL,
|
||||
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
|
||||
STAFF_EMOJIS: Object.fromEntries((process.env.STAFF_EMOJIS||'').split(',').map(s=>s.trim()).filter(Boolean).map(p=>{const i=p.indexOf(':');return i===-1?null:[p.slice(0,i).trim(),p.slice(i+1).trim()];}).filter(Boolean)),
|
||||
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
||||
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
|
||||
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
|
||||
GAME_LIST: process.env.GAME_LIST || '',
|
||||
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
|
||||
EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null,
|
||||
// Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming).
|
||||
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
|
||||
DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_ESCALATED2_CHANNEL_ID || null,
|
||||
@@ -96,8 +96,6 @@ const CONFIG = {
|
||||
AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
|
||||
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
|
||||
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
|
||||
USE_THREADS: process.env.USE_THREADS === 'true',
|
||||
THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null,
|
||||
BUTTON_LABEL_CLOSE: process.env.BUTTON_LABEL_CLOSE || 'Close Ticket',
|
||||
BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim',
|
||||
BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim',
|
||||
|
||||
@@ -20,8 +20,7 @@ const {
|
||||
sanitizeEmbedText
|
||||
} = require('./utils');
|
||||
const { getGmailClient } = require('./services/gmail');
|
||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||
const { getEmailRouting } = require('./services/guildSettings');
|
||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
||||
const { enqueueSend } = require('./services/channelQueue');
|
||||
|
||||
@@ -192,27 +191,21 @@ async function poll(client) {
|
||||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||||
|
||||
try {
|
||||
const routing = await getEmailRouting(guild.id);
|
||||
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
|
||||
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
|
||||
parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null;
|
||||
} else {
|
||||
const parentId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
parentCategoryIdForTicket = parentId;
|
||||
try {
|
||||
ticketChan = await guild.channels.create({
|
||||
name: chanName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId
|
||||
});
|
||||
} catch (createErr) {
|
||||
console.error('Channel create error (email ticket):', createErr);
|
||||
throw createErr;
|
||||
}
|
||||
const parentId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
parentCategoryIdForTicket = parentId;
|
||||
try {
|
||||
ticketChan = await guild.channels.create({
|
||||
name: chanName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId
|
||||
});
|
||||
} catch (createErr) {
|
||||
console.error('Channel create error (email ticket):', createErr);
|
||||
throw createErr;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Channel create error (payload):', {
|
||||
|
||||
@@ -16,11 +16,10 @@ const {
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
|
||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
|
||||
const { setEmailRouting } = require('../services/guildSettings');
|
||||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||||
const { runEscalation, runDeescalation } = require('./commands');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
@@ -78,26 +77,6 @@ async function handleButton(interaction) {
|
||||
return await interaction.showModal(modal);
|
||||
}
|
||||
|
||||
// --- Email routing (no ticket required) ---
|
||||
if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') {
|
||||
const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category';
|
||||
try {
|
||||
await setEmailRouting(interaction.guild.id, value);
|
||||
const label = value === 'thread' ? '**threads**' : '**channels in a category**';
|
||||
await interaction.reply({
|
||||
content: `Done. New email tickets will now be created as ${label}.`,
|
||||
ephemeral: true
|
||||
});
|
||||
} catch (err) {
|
||||
logError('email-routing-button', err, interaction).catch(() => {});
|
||||
await interaction.reply({
|
||||
content: 'Failed to update email routing.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ticket-scoped buttons (need ticket lookup) ---
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
@@ -339,7 +318,7 @@ async function handleClaim(interaction, ticket) {
|
||||
freshTicket.claimedBy = claimerLabel;
|
||||
freshTicket.claimerId = interaction.user.id;
|
||||
|
||||
const claimerEmoji = '🎫';
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||
|
||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||
@@ -590,10 +569,6 @@ async function handleTicketModal(interaction) {
|
||||
const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80);
|
||||
const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||
|
||||
const useThread =
|
||||
interaction.customId === 'ticket_modal_thread' ||
|
||||
(interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID);
|
||||
|
||||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||
if (!rateLimit.allowed) {
|
||||
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
||||
@@ -610,51 +585,39 @@ async function handleTicketModal(interaction) {
|
||||
|
||||
let channel;
|
||||
let parentCategoryIdForTicket = null;
|
||||
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||
try {
|
||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
|
||||
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
||||
} catch (err) {
|
||||
console.error('Discord ticket thread create failed:', err.message);
|
||||
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
|
||||
}
|
||||
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
|
||||
} else {
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
||||
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
||||
}
|
||||
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,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
id: interaction.user.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('guild.channels.create (ticket modal):', err);
|
||||
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
|
||||
}
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
||||
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
||||
}
|
||||
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,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
id: interaction.user.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('guild.channels.create (ticket modal):', err);
|
||||
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
|
||||
}
|
||||
|
||||
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
|
||||
|
||||
@@ -13,14 +13,12 @@ const {
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { getPriorityEmoji, replaceVariables } = require('../utils');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { getEmailRouting } = require('../services/guildSettings');
|
||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
||||
const { setNotifyDm } = require('../services/staffSettings');
|
||||
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
|
||||
const { handleSetupCommand } = require('./setup');
|
||||
const { pendingCloses } = require('./pendingCloses');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
@@ -221,39 +219,6 @@ 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);
|
||||
}
|
||||
|
||||
// /email-routing – switch where new email tickets are created (thread vs category)
|
||||
if (interaction.commandName === 'email-routing') {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
try {
|
||||
const current = await getEmailRouting(interaction.guild.id);
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('email_routing_thread')
|
||||
.setLabel('Threads')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('email_routing_category')
|
||||
.setLabel('Category channels')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
await interaction.editReply({
|
||||
content: `Email ticket routing: **${current}**. Choose where new email tickets should be created:`,
|
||||
components: [row]
|
||||
});
|
||||
} catch (err) {
|
||||
logError('email-routing-command', err, interaction).catch(() => {});
|
||||
await interaction.editReply('Failed to load routing options.').catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate.
|
||||
if (interaction.commandName === 'escalate') {
|
||||
const reason = null;
|
||||
@@ -926,48 +891,38 @@ async function handleContextMenu(interaction) {
|
||||
|
||||
let channel;
|
||||
let parentCategoryIdForTicket = null;
|
||||
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||
try {
|
||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
|
||||
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
||||
} catch (err) {
|
||||
console.error('Discord ticket thread create (from message) failed:', err.message);
|
||||
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
|
||||
}
|
||||
} else {
|
||||
let parentId;
|
||||
try {
|
||||
parentId = 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.');
|
||||
}
|
||||
parentCategoryIdForTicket = parentId;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
id: message.author.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('guild.channels.create (context menu ticket):', err);
|
||||
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
|
||||
}
|
||||
let parentId;
|
||||
try {
|
||||
parentId = 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.');
|
||||
}
|
||||
parentCategoryIdForTicket = parentId;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
id: message.author.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
} 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}`;
|
||||
|
||||
@@ -43,8 +43,6 @@ async function handleDiscordReply(m) {
|
||||
}
|
||||
}
|
||||
|
||||
const discordUser = m.member?.displayName || m.author.username;
|
||||
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +86,6 @@ async function handleDiscordReply(m) {
|
||||
m.content,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
msgId,
|
||||
m.author.id
|
||||
);
|
||||
|
||||
106
handlers/messages.js.bak3-20260421
Normal file
106
handlers/messages.js.bak3-20260421
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Discord messageCreate handler – forwards staff replies to Gmail.
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail } = require('../utils');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { updateTicketActivity } = require('../services/tickets');
|
||||
const { getNotifyDm } = require('../services/staffSettings');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only).
|
||||
*/
|
||||
async function handleDiscordReply(m) {
|
||||
if (m.author.bot || m.interaction) return;
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||
if (!ticket) return;
|
||||
|
||||
// Track whether last message is from staff or customer
|
||||
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
|
||||
const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
|
||||
Ticket.updateOne(
|
||||
{ discordThreadId: m.channel.id },
|
||||
{ $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } }
|
||||
).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) {
|
||||
const staffMember = 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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authorName =
|
||||
m.member?.displayName ||
|
||||
m.member?.nickname ||
|
||||
m.author.globalName ||
|
||||
m.author.username;
|
||||
|
||||
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 recipientEmail = extractRawEmail(recipient).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
|
||||
console.warn('Bad recipient for reply:', recipientEmail);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendGmailReply(
|
||||
ticket.gmailThreadId,
|
||||
m.content,
|
||||
recipientEmail,
|
||||
subject,
|
||||
authorName,
|
||||
msgId,
|
||||
m.author.id
|
||||
);
|
||||
|
||||
await updateTicketActivity(ticket.gmailThreadId);
|
||||
} catch (e) {
|
||||
console.error('REPLY ERROR:', e);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleDiscordReply };
|
||||
@@ -1,656 +0,0 @@
|
||||
/**
|
||||
* /setup wizard – multi-step panel configuration (panel name, support role,
|
||||
* ticket category, transcript channel, panel channel).
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
ChannelType,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
RoleSelectMenuBuilder,
|
||||
ChannelSelectMenuBuilder
|
||||
} = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { enqueueSend } = require('../services/channelQueue');
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
/** @type {Map<string, { step: number, panelName?: string, roleIds?: string[], ticketType?: 'channel'|'thread', categoryId?: string, categoryName?: string, threadChannelId?: string, threadChannelName?: string, transcriptChannelId?: string, panelChannelId?: string, createdAt: number }>} */
|
||||
const setupState = new Map();
|
||||
|
||||
const PREFIX = 'setup_';
|
||||
const PREFIX_BUTTON = PREFIX;
|
||||
const PREFIX_MODAL = PREFIX + 'modal_';
|
||||
const PREFIX_SELECT = PREFIX + 'select_';
|
||||
|
||||
function getState(userId) {
|
||||
const s = setupState.get(userId);
|
||||
if (!s) return null;
|
||||
if (Date.now() - s.createdAt > WIZARD_TIMEOUT_MS) {
|
||||
setupState.delete(userId);
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setState(userId, data) {
|
||||
const existing = setupState.get(userId) || { createdAt: Date.now() };
|
||||
setupState.set(userId, { ...existing, ...data });
|
||||
}
|
||||
|
||||
function clearState(userId) {
|
||||
setupState.delete(userId);
|
||||
}
|
||||
|
||||
function step1Embed(panelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 1/5 Set the panel name')
|
||||
.setDescription(
|
||||
'Use the button to set the panel name and continue.\n(This can be changed later.)'
|
||||
)
|
||||
.addFields({ name: 'Current Name', value: panelName ? `\`${panelName}\`` : 'Not set' });
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'setname')
|
||||
.setLabel('Set name')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('⚙️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_1')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!panelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
function step2Embed(roleLabels) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 2/5 Select the support team role(s)')
|
||||
.setDescription(
|
||||
'The support roles will be automatically added to this panel\'s tickets so they can assist people as needed.\n' +
|
||||
'Use the dropdown to select roles.\n' +
|
||||
'Not seeing your role? Try searching for it inside the dropdown.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Role(s)',
|
||||
value: roleLabels && roleLabels.length ? roleLabels.join(', ') : 'None selected'
|
||||
});
|
||||
|
||||
const select = new RoleSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'roles')
|
||||
.setPlaceholder('Select all the roles for your support team')
|
||||
.setMinValues(1)
|
||||
.setMaxValues(5);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_2')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_2')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!roleLabels || roleLabels.length === 0)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step3Embed(state) {
|
||||
const ticketType = state.ticketType;
|
||||
const categoryName = state.categoryName;
|
||||
const threadChannelName = state.threadChannelName;
|
||||
|
||||
if (!ticketType) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 How should tickets be created?')
|
||||
.setDescription(
|
||||
'**Channels:** Each ticket is a channel in a category (classic layout).\n' +
|
||||
'**Threads:** Each ticket is a private thread under a text channel (compact).\n' +
|
||||
'**Both:** Create one panel with two buttons (thread + category).'
|
||||
)
|
||||
.addFields({ name: 'Choice', value: 'Select below' });
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_channel')
|
||||
.setLabel('Channels in category')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📁'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_thread')
|
||||
.setLabel('Private threads')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_both')
|
||||
.setLabel('Both (thread + category)')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📋'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️')
|
||||
);
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
if (ticketType === 'both') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select category and thread channel (both)')
|
||||
.setDescription(
|
||||
'The panel will have two buttons: one creates ticket **threads**, one creates ticket **channels**.\n' +
|
||||
'Select the category for channels and the text channel for threads.'
|
||||
)
|
||||
.addFields(
|
||||
{ name: 'Category (for channels)', value: categoryName ? `\`${categoryName}\`` : 'None selected', inline: true },
|
||||
{ name: 'Channel (for threads)', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected', inline: true }
|
||||
);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(
|
||||
new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'category')
|
||||
.setPlaceholder('Select category for channels')
|
||||
.addChannelTypes(ChannelType.GuildCategory)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'thread_channel')
|
||||
.setPlaceholder('Select channel for threads')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
const row3 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_both_channel')
|
||||
.setLabel('Channels only')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_thread')
|
||||
.setLabel('Threads only')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!(categoryName && threadChannelName))
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2, row3] };
|
||||
}
|
||||
|
||||
if (ticketType === 'channel') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select the ticket category')
|
||||
.setDescription(
|
||||
'The selected category is where ticket **channels** will be created.\n' +
|
||||
'Use the dropdown to select the category.'
|
||||
)
|
||||
.addFields({ name: 'Selected Category', value: categoryName ? `\`${categoryName}\`` : 'None selected' });
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'category')
|
||||
.setPlaceholder('Select a category')
|
||||
.addChannelTypes(ChannelType.GuildCategory)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
|
||||
.setLabel('Change to Threads')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!categoryName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
// ticketType === 'thread'
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select the channel for ticket threads')
|
||||
.setDescription(
|
||||
'Ticket **threads** will be created as private threads under the selected text channel.\n' +
|
||||
'Use the dropdown to select the channel.'
|
||||
)
|
||||
.addFields({ name: 'Selected Channel', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected' });
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'thread_channel')
|
||||
.setPlaceholder('Select a text channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
|
||||
.setLabel('Change to Channels')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!threadChannelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step4Embed(channelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 4/5 Select the transcript channel')
|
||||
.setDescription(
|
||||
'The selected channel is where transcripts will be saved when tickets are closed.\n' +
|
||||
'Use the dropdown to select the channel.\n' +
|
||||
'Not seeing your channel? Try searching for it inside the dropdown.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Channel',
|
||||
value: channelName ? `\`${channelName}\`` : 'Not selected'
|
||||
});
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'transcript')
|
||||
.setPlaceholder('Select a channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_4')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_4')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!channelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step5Embed(channelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 5/5 Send the panel into a channel')
|
||||
.setDescription(
|
||||
'The ticket creation panel is what the community will use to create tickets.\n' +
|
||||
'Use the dropdown to select the channel to send the panel into.\n' +
|
||||
'Not seeing your channel? Try searching for it inside the dropdown.\n' +
|
||||
'Sending not working? Run `/panel` in the channel directly.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Channel',
|
||||
value: channelName ? `\`${channelName}\`` : 'Not selected'
|
||||
});
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'panel_channel')
|
||||
.setPlaceholder('Select a channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_5')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'finish')
|
||||
.setLabel('Finish')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!channelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /setup slash command – send Step 1.
|
||||
*/
|
||||
async function handleSetupCommand(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
setState(interaction.user.id, { step: 1, panelName: null });
|
||||
const payload = step1Embed(null);
|
||||
await interaction.editReply(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup button (Set name, Back, Save & Continue, Finish).
|
||||
*/
|
||||
async function handleSetupButton(interaction) {
|
||||
const customId = interaction.customId;
|
||||
if (!customId.startsWith(PREFIX_BUTTON)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Set name → show modal
|
||||
if (customId === PREFIX_BUTTON + 'setname') {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(PREFIX_MODAL + 'name')
|
||||
.setTitle('Panel name');
|
||||
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId('panel_name')
|
||||
.setLabel('Panel name')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('e.g. New Panel')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100);
|
||||
if (state.panelName) input.setValue(state.panelName);
|
||||
modal.addComponents(new ActionRowBuilder().addComponents(input));
|
||||
await interaction.showModal(modal);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Back
|
||||
if (customId.startsWith(PREFIX_BUTTON + 'back_')) {
|
||||
const step = parseInt(customId.replace(PREFIX_BUTTON + 'back_', ''), 10);
|
||||
const nextStep = step - 1;
|
||||
setState(userId, { step: nextStep });
|
||||
let payload;
|
||||
if (nextStep === 1) payload = step1Embed(state.panelName);
|
||||
else if (nextStep === 2) payload = step2Embed(state.roleLabels);
|
||||
else if (nextStep === 3) payload = step3Embed(state);
|
||||
else if (nextStep === 4) payload = step4Embed(state.transcriptChannelName);
|
||||
else payload = step5Embed(state.panelChannelName);
|
||||
await interaction.update(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save & Continue (steps 1–4)
|
||||
if (customId === PREFIX_BUTTON + 'continue_1') {
|
||||
setState(userId, { step: 2 });
|
||||
await interaction.update(step2Embed(state.roleLabels));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_2') {
|
||||
setState(userId, { step: 3 });
|
||||
await interaction.update(step3Embed({ ...state, step: 3 }));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_channel') {
|
||||
setState(userId, { ticketType: 'channel', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_thread') {
|
||||
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_both') {
|
||||
setState(userId, { ticketType: 'both', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear') {
|
||||
setState(userId, { ticketType: null, categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear_thread') {
|
||||
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear_both_channel') {
|
||||
setState(userId, { ticketType: 'channel', threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_3') {
|
||||
setState(userId, { step: 4 });
|
||||
await interaction.update(step4Embed(state.transcriptChannelName));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_4') {
|
||||
setState(userId, { step: 5 });
|
||||
await interaction.update(step5Embed(state.panelChannelName));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Finish
|
||||
if (customId === PREFIX_BUTTON + 'finish') {
|
||||
const hasTicketTarget =
|
||||
(state.ticketType === 'channel' && state.categoryId) ||
|
||||
(state.ticketType === 'thread' && state.threadChannelId) ||
|
||||
(state.ticketType === 'both' && state.categoryId && state.threadChannelId);
|
||||
if (!state.panelChannelId || !hasTicketTarget || !state.roleIds?.length) {
|
||||
await interaction.reply({
|
||||
content: 'Please complete all steps (panel name, support role, ticket type + category/channel, transcript channel, panel channel).',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await interaction.client.channels.fetch(state.panelChannelId);
|
||||
const title = state.panelName || 'Indifferent Broccoli Tickets';
|
||||
const description = 'Need help? Click below to create a ticket. 🎟';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(0x2ecc71)
|
||||
.setThumbnail(CONFIG.LOGO_URL || null)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
let row;
|
||||
if (state.ticketType === 'both') {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket (channel)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
} else {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('✅')
|
||||
);
|
||||
}
|
||||
|
||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||
|
||||
const envLines = state.ticketType === 'both'
|
||||
? [`DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`, `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`]
|
||||
: [state.ticketType === 'thread'
|
||||
? `DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`
|
||||
: `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`];
|
||||
const envSnippet = [
|
||||
'**Add these to your `.env` file** (optional – only if you want to use these values for new Discord tickets):',
|
||||
'```',
|
||||
...envLines,
|
||||
`ROLE_ID_TO_PING=${state.roleIds[0]}`,
|
||||
`TRANSCRIPT_CHANNEL_ID=${state.transcriptChannelId}`,
|
||||
`LOGGING_CHANNEL_ID=${state.transcriptChannelId}`,
|
||||
'```'
|
||||
].join('\n');
|
||||
|
||||
await interaction.update({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Setup complete')
|
||||
.setDescription(
|
||||
`Panel **${title}** has been sent to ${channel}.\n\n` +
|
||||
envSnippet
|
||||
)
|
||||
],
|
||||
components: []
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Setup finish error:', err);
|
||||
await interaction.reply({
|
||||
content: `Failed to send panel: ${err.message}`,
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
}
|
||||
clearState(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup modal submit (panel name).
|
||||
*/
|
||||
async function handleSetupModal(interaction) {
|
||||
if (!interaction.customId.startsWith(PREFIX_MODAL)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interaction.customId === PREFIX_MODAL + 'name') {
|
||||
const panelName = interaction.fields.getTextInputValue('panel_name').trim();
|
||||
setState(userId, { panelName, step: 1 });
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const payload = step1Embed(panelName);
|
||||
await interaction.editReply(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup select menus (roles, category, transcript channel, panel channel).
|
||||
*/
|
||||
async function handleSetupSelect(interaction) {
|
||||
const customId = interaction.customId;
|
||||
if (!customId.startsWith(PREFIX_SELECT)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'roles') {
|
||||
const roles = interaction.roles;
|
||||
const roleIds = [...roles.keys()];
|
||||
const roleLabels = [...roles.values()].map(r => r.name);
|
||||
setState(userId, { roleIds, roleLabels });
|
||||
await interaction.update(step2Embed(roleLabels));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'category') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
categoryId: channel?.id,
|
||||
categoryName: channel?.name
|
||||
});
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_SELECT + 'thread_channel') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
threadChannelId: channel?.id,
|
||||
threadChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'transcript') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
transcriptChannelId: channel?.id,
|
||||
transcriptChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step4Embed(channel?.name));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'panel_channel') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
panelChannelId: channel?.id,
|
||||
panelChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step5Embed(channel?.name));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PREFIX_BUTTON,
|
||||
PREFIX_MODAL,
|
||||
PREFIX_SELECT,
|
||||
handleSetupCommand,
|
||||
handleSetupButton,
|
||||
handleSetupModal,
|
||||
handleSetupSelect
|
||||
};
|
||||
@@ -54,12 +54,6 @@ mongoose.model('Tag', new mongoose.Schema({
|
||||
useCount: { type: Number, default: 0 }
|
||||
}));
|
||||
|
||||
mongoose.model('GuildSettings', new mongoose.Schema({
|
||||
guildId: { type: String, required: true, unique: true },
|
||||
emailRouting: { type: String, enum: ['thread', 'category'], default: 'category' },
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
}));
|
||||
|
||||
mongoose.model('StaffSettings', new mongoose.Schema({
|
||||
userId: { type: String, required: true, unique: true },
|
||||
guildId: { type: String, required: true },
|
||||
|
||||
@@ -21,7 +21,6 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
// Ticket settings
|
||||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
|
||||
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
|
||||
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
|
||||
// Escalation categories
|
||||
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
||||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
||||
|
||||
@@ -239,16 +239,14 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
* @param {string} replyText - Reply text
|
||||
* @param {string} recipientEmail - Recipient email
|
||||
* @param {string} subject - Subject line
|
||||
* @param {string} discordUser - Discord user name
|
||||
* @param {string} messageId - Message ID (optional)
|
||||
* @param {string} userId - Discord user ID for signature (optional)
|
||||
* @param {string} userId - Discord user ID for optional personal valediction/tagline (optional)
|
||||
*/
|
||||
async function sendGmailReply(
|
||||
threadId,
|
||||
replyText,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
messageId,
|
||||
userId = null
|
||||
) {
|
||||
@@ -265,50 +263,44 @@ async function sendGmailReply(
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
safeSubject
|
||||
).toString('base64')}?=`;
|
||||
const safeUser = escapeHtml(discordUser);
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const companySignatureText = (CONFIG.SIGNATURE || '').replace(/<br>/g, '\n');
|
||||
|
||||
// Get staff signature if userId provided
|
||||
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
|
||||
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
|
||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = signatureBlocks.text;
|
||||
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeCompanySigHtml}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div>
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65" alt="Indifferent Broccoli"><br>` : ''}
|
||||
Indifferent Broccoli Support<br>
|
||||
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
||||
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
||||
<br>
|
||||
<em>"We eat lag for breakfast. Whatever."</em>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
|
||||
const plainBody = [];
|
||||
plainBody.push(replyText);
|
||||
if (safeStaffSigText) {
|
||||
plainBody.push(safeStaffSigText);
|
||||
}
|
||||
plainBody.push('');
|
||||
plainBody.push('------------------------------');
|
||||
plainBody.push('');
|
||||
plainBody.push(companySignatureText);
|
||||
plainBody.push(replyText);
|
||||
if (safeStaffSigText) {
|
||||
plainBody.push('');
|
||||
plainBody.push(safeStaffSigText);
|
||||
}
|
||||
plainBody.push('');
|
||||
plainBody.push('Indifferent Broccoli Support');
|
||||
plainBody.push('https://indifferentbroccoli.com/');
|
||||
plainBody.push('Join us on Discord: https://discord.gg/2vmfrrtvJY');
|
||||
plainBody.push('');
|
||||
plainBody.push('"We eat lag for breakfast. Whatever."');
|
||||
|
||||
const raw = Buffer.from([
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
|
||||
346
services/gmail.js.bak3-20260421
Normal file
346
services/gmail.js.bak3-20260421
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Gmail service – OAuth client, send reply, send ticket-closed email.
|
||||
*/
|
||||
const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
||||
const { getStaffSignatureBlocks } = require('./staffSignature');
|
||||
const { logError } = require('./debugLog');
|
||||
const { readEnvFile } = require('./configPersistence');
|
||||
|
||||
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
|
||||
|
||||
function getGmailClient() {
|
||||
const auth = new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET
|
||||
);
|
||||
auth.setCredentials({ refresh_token: CONFIG.REFRESH_TOKEN });
|
||||
return google.gmail({ version: 'v1', auth });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read REFRESH_TOKEN from .env, update in-memory config, and probe Google.
|
||||
* Used by the internal /gmail/reload endpoint so the weekly reauth chore does
|
||||
* not require a full container restart.
|
||||
*
|
||||
* Throws if the env file is missing the token, or if the probe call (getProfile)
|
||||
* fails — the caller surfaces the error so the UI can see why.
|
||||
*
|
||||
* @returns {Promise<{emailAddress: string}>}
|
||||
*/
|
||||
async function reloadGmailClient() {
|
||||
const envMap = readEnvFile();
|
||||
const newToken = envMap.get('REFRESH_TOKEN');
|
||||
if (!newToken) {
|
||||
const err = new Error('REFRESH_TOKEN not set in .env');
|
||||
err.code = 'ENOTOKEN';
|
||||
throw err;
|
||||
}
|
||||
process.env.REFRESH_TOKEN = newToken;
|
||||
CONFIG.REFRESH_TOKEN = newToken;
|
||||
const gmail = getGmailClient();
|
||||
const profile = await gmail.users.getProfile({ userId: 'me' });
|
||||
return { emailAddress: profile.data.emailAddress };
|
||||
}
|
||||
|
||||
async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
// Send to the ticket sender (customer), not derived from thread (which can be support)
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const lastMsg = [...messages].reverse()[0];
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {
|
||||
/* use ticket.subject and no In-Reply-To if thread fetch fails */
|
||||
}
|
||||
|
||||
const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
finalSubject
|
||||
).toString('base64')}?=`;
|
||||
|
||||
const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${safeCloseMessage}</p>
|
||||
<p style="margin-top: 16px;">${safeCloseSignature}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
msgId ? `References: ${msgId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(rawHeaders.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket closed email error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// StaffSignature model is registered in models.js; re-import here for use in this file
|
||||
const { mongoose } = require('../db-connection');
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
|
||||
/**
|
||||
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
|
||||
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
|
||||
* @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated")
|
||||
* @param {string} messageBody - Plain or HTML message body
|
||||
* @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord")
|
||||
* @param {string} [userId] - Discord user ID for signature (optional)
|
||||
*/
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const lastMsg = [...messages].reverse()[0];
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
|
||||
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
|
||||
// Get staff signature if userId provided
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const serverDisplayName = label;
|
||||
const safeCloseMessage = safeBody;
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${safeCloseMessage}</p>
|
||||
<p style="margin-top: 16px;">${safeCloseSignature}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
msgId ? `References: ${msgId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(rawHeaders.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket notification email error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Gmail reply to a ticket
|
||||
* @param {string} threadId - Gmail thread ID
|
||||
* @param {string} replyText - Reply text
|
||||
* @param {string} recipientEmail - Recipient email
|
||||
* @param {string} subject - Subject line
|
||||
* @param {string} authorName - Replier's Discord server display name (caller resolves from member.displayName)
|
||||
* @param {string} messageId - Message ID (optional)
|
||||
* @param {string} userId - Discord user ID for optional personal valediction/tagline (optional)
|
||||
*/
|
||||
async function sendGmailReply(
|
||||
threadId,
|
||||
replyText,
|
||||
recipientEmail,
|
||||
subject,
|
||||
authorName,
|
||||
messageId,
|
||||
userId = null
|
||||
) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeRecipient)) {
|
||||
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
const safeMessageId = sanitizeHeaderValue(messageId);
|
||||
const safeSubject = sanitizeHeaderValue(`Re: ${subject}`);
|
||||
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
safeSubject
|
||||
).toString('base64')}?=`;
|
||||
const safeAuthor = escapeHtml(authorName || '');
|
||||
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
|
||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = signatureBlocks.text;
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
<div>
|
||||
<strong>${safeAuthor}</strong><br>
|
||||
Indifferent Broccoli Support<br>
|
||||
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
||||
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
||||
<br>
|
||||
<em>"We eat lag for breakfast. Whatever."</em>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
|
||||
const plainBody = [];
|
||||
plainBody.push(replyText);
|
||||
if (safeStaffSigText) {
|
||||
plainBody.push('');
|
||||
plainBody.push(safeStaffSigText);
|
||||
}
|
||||
plainBody.push('');
|
||||
plainBody.push(authorName || '');
|
||||
plainBody.push('Indifferent Broccoli Support');
|
||||
plainBody.push('https://indifferentbroccoli.com/');
|
||||
plainBody.push('Join us on Discord: https://discord.gg/2vmfrrtvJY');
|
||||
plainBody.push('');
|
||||
plainBody.push('"We eat lag for breakfast. Whatever."');
|
||||
|
||||
const raw = Buffer.from([
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${safeRecipient}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
safeMessageId ? `In-Reply-To: ${safeMessageId}` : '',
|
||||
safeMessageId ? `References: ${safeMessageId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/alternative; boundary="' + boundary + '"',
|
||||
'',
|
||||
'--' + boundary,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
...plainBody,
|
||||
'',
|
||||
'--' + boundary,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody,
|
||||
'',
|
||||
'--' + boundary + '--'
|
||||
].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId }
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGmailClient,
|
||||
reloadGmailClient,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
sendTicketNotificationEmail
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Guild-specific settings (e.g. email ticket routing).
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
|
||||
const GuildSettings = mongoose.model('GuildSettings');
|
||||
|
||||
/**
|
||||
* Get email ticket routing for a guild. Returns 'thread' or 'category'.
|
||||
* If not set, defaults to 'category'.
|
||||
* @param {string} guildId
|
||||
* @returns {Promise<'thread'|'category'>}
|
||||
*/
|
||||
async function getEmailRouting(guildId) {
|
||||
const doc = await GuildSettings.findOne({ guildId }).select('emailRouting').lean();
|
||||
if (doc && doc.emailRouting) return doc.emailRouting;
|
||||
return 'category';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set email ticket routing for a guild.
|
||||
* @param {string} guildId
|
||||
* @param {'thread'|'category'} value
|
||||
*/
|
||||
async function setEmailRouting(guildId, value) {
|
||||
await GuildSettings.findOneAndUpdate(
|
||||
{ guildId },
|
||||
{ $set: { emailRouting: value, updatedAt: new Date() } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getEmailRouting, setEmailRouting };
|
||||
@@ -273,145 +273,49 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
|
||||
}
|
||||
|
||||
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
||||
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
||||
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
||||
if (!parentChannel) {
|
||||
throw new Error('Thread parent channel not found');
|
||||
}
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||
} catch (e) {
|
||||
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||||
throw new Error('Ticket category not found or could not be allocated');
|
||||
}
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
await thread.members.add(userId);
|
||||
// Add all members with the support role so they can see and reply in the thread
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
if (memberId === userId) continue; // already added
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: userId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
} else {
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||
} catch (e) {
|
||||
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||||
throw new Error('Ticket category not found or could not be allocated');
|
||||
}
|
||||
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: userId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('guild.channels.create (createTicketChannel):', e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return channel;
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('guild.channels.create (createTicketChannel):', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
|
||||
* Adds creator and all members with ROLE_ID_TO_PING.
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {number} ticketNumber
|
||||
* @param {string} creatorUserId
|
||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||
*/
|
||||
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
|
||||
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
|
||||
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
|
||||
const parentChannel = guild.channels.cache.get(parentId);
|
||||
if (!parentChannel) throw new Error('Discord thread parent channel not found');
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
await thread.members.add(creatorUserId);
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
if (memberId === creatorUserId) continue;
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
|
||||
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {number} ticketNumber
|
||||
* @param {string} chanName
|
||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||
*/
|
||||
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
|
||||
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
|
||||
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
|
||||
const parentChannel = guild.channels.cache.get(parentId);
|
||||
if (!parentChannel) throw new Error('Email thread parent channel not found');
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: chanName || `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
return channel;
|
||||
}
|
||||
|
||||
// --- LIMITS & PERMISSIONS ---
|
||||
@@ -649,8 +553,6 @@ module.exports = {
|
||||
getNextTicketNumber,
|
||||
getOrCreateTicketCategory,
|
||||
cleanupEmptyOverflowCategory,
|
||||
createDiscordTicketAsThread,
|
||||
createEmailTicketAsThread,
|
||||
RENAME_WINDOW_MS,
|
||||
RENAME_LIMIT,
|
||||
getSenderLocal,
|
||||
|
||||
Reference in New Issue
Block a user