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
|
# Ticket creation: set one or both; /panel and /email-routing choose behavior
|
||||||
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
|
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
|
||||||
TICKET_CATEGORY_ID= # Category for email-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.)
|
# 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
|
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_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)
|
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 (comma-separated; used for detection and tags) ---
|
||||||
GAME_LIST=Project Zomboid, Minecraft, ...
|
GAME_LIST=Project Zomboid, Minecraft, ...
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const { mongoose } = require('./db-connection');
|
|||||||
// Handlers
|
// Handlers
|
||||||
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
||||||
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
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');
|
const { handleDiscordReply } = require('./handlers/messages');
|
||||||
|
|
||||||
// Services & jobs
|
// Services & jobs
|
||||||
@@ -113,30 +112,10 @@ async function runHandler(name, interaction, fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client.on('interactionCreate', async interaction => {
|
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()) {
|
if (interaction.isButton()) {
|
||||||
return runHandler('handleButton', interaction, () => handleButton(interaction));
|
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_')) {
|
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
|
||||||
// Handle signature modal submit
|
// Handle signature modal submit
|
||||||
try {
|
try {
|
||||||
@@ -176,11 +155,6 @@ client.on('interactionCreate', async interaction => {
|
|||||||
return runHandler('handleTicketModal', interaction, () => handleTicketModal(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()) {
|
if (interaction.isChatInputCommand()) {
|
||||||
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
|
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,13 +205,6 @@ async function registerCommands() {
|
|||||||
InteractionContextType.PrivateChannel
|
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()
|
new SlashCommandBuilder()
|
||||||
.setName('panel')
|
.setName('panel')
|
||||||
.setDescription('Create a ticket panel for users to open Discord tickets')
|
.setDescription('Create a ticket panel for users to open Discord tickets')
|
||||||
@@ -253,13 +246,6 @@ async function registerCommands() {
|
|||||||
.setRequired(false)
|
.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()
|
new SlashCommandBuilder()
|
||||||
.setName('notifydm')
|
.setName('notifydm')
|
||||||
.setDescription('Toggle DM notifications when your ticket receives a customer reply.')
|
.setDescription('Toggle DM notifications when your ticket receives a customer reply.')
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ const CONFIG = {
|
|||||||
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
|
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
|
||||||
LOGO_URL: process.env.LOGO_URL,
|
LOGO_URL: process.env.LOGO_URL,
|
||||||
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
|
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),
|
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
|
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'),
|
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
|
||||||
GAME_LIST: process.env.GAME_LIST || '',
|
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).
|
// 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,
|
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
|
||||||
DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_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_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
|
||||||
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
|
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
|
||||||
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
|
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_CLOSE: process.env.BUTTON_LABEL_CLOSE || 'Close Ticket',
|
||||||
BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim',
|
BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim',
|
||||||
BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim',
|
BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim',
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ const {
|
|||||||
sanitizeEmbedText
|
sanitizeEmbedText
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
const { getGmailClient } = require('./services/gmail');
|
||||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||||
const { getEmailRouting } = require('./services/guildSettings');
|
|
||||||
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
||||||
const { enqueueSend } = require('./services/channelQueue');
|
const { enqueueSend } = require('./services/channelQueue');
|
||||||
|
|
||||||
@@ -192,27 +191,21 @@ async function poll(client) {
|
|||||||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const routing = await getEmailRouting(guild.id);
|
const parentId = await getOrCreateTicketCategory(
|
||||||
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
|
guild,
|
||||||
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
|
CONFIG.TICKET_CATEGORY_ID,
|
||||||
parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null;
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
} else {
|
);
|
||||||
const parentId = await getOrCreateTicketCategory(
|
parentCategoryIdForTicket = parentId;
|
||||||
guild,
|
try {
|
||||||
CONFIG.TICKET_CATEGORY_ID,
|
ticketChan = await guild.channels.create({
|
||||||
CONFIG.TICKET_CATEGORY_NAME
|
name: chanName,
|
||||||
);
|
type: ChannelType.GuildText,
|
||||||
parentCategoryIdForTicket = parentId;
|
parent: parentId
|
||||||
try {
|
});
|
||||||
ticketChan = await guild.channels.create({
|
} catch (createErr) {
|
||||||
name: chanName,
|
console.error('Channel create error (email ticket):', createErr);
|
||||||
type: ChannelType.GuildText,
|
throw createErr;
|
||||||
parent: parentId
|
|
||||||
});
|
|
||||||
} catch (createErr) {
|
|
||||||
console.error('Channel create error (email ticket):', createErr);
|
|
||||||
throw createErr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Channel create error (payload):', {
|
console.error('Channel create error (payload):', {
|
||||||
|
|||||||
@@ -16,11 +16,10 @@ const {
|
|||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG } = require('../config');
|
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 { sendTicketClosedEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
|
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
|
||||||
const { setEmailRouting } = require('../services/guildSettings');
|
|
||||||
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
|
||||||
const { runEscalation, runDeescalation } = require('./commands');
|
const { runEscalation, runDeescalation } = require('./commands');
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
@@ -78,26 +77,6 @@ async function handleButton(interaction) {
|
|||||||
return await interaction.showModal(modal);
|
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) ---
|
// --- Ticket-scoped buttons (need ticket lookup) ---
|
||||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
@@ -339,7 +318,7 @@ async function handleClaim(interaction, ticket) {
|
|||||||
freshTicket.claimedBy = claimerLabel;
|
freshTicket.claimedBy = claimerLabel;
|
||||||
freshTicket.claimerId = interaction.user.id;
|
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 creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||||
|
|
||||||
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
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 subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80);
|
||||||
const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
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);
|
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||||
if (!rateLimit.allowed) {
|
if (!rateLimit.allowed) {
|
||||||
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
||||||
@@ -610,51 +585,39 @@ async function handleTicketModal(interaction) {
|
|||||||
|
|
||||||
let channel;
|
let channel;
|
||||||
let parentCategoryIdForTicket = null;
|
let parentCategoryIdForTicket = null;
|
||||||
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
let parentId;
|
||||||
try {
|
try {
|
||||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
|
parentId = await getOrCreateTicketCategory(
|
||||||
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
guild,
|
||||||
} catch (err) {
|
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||||
console.error('Discord ticket thread create failed:', err.message);
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
|
);
|
||||||
}
|
} catch (err) {
|
||||||
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
||||||
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
|
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
||||||
} else {
|
}
|
||||||
let parentId;
|
parentCategoryIdForTicket = parentId;
|
||||||
try {
|
try {
|
||||||
parentId = await getOrCreateTicketCategory(
|
// 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.
|
||||||
guild,
|
channel = await guild.channels.create({
|
||||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
name: unclaimedName,
|
||||||
CONFIG.TICKET_CATEGORY_NAME
|
type: ChannelType.GuildText,
|
||||||
);
|
parent: parentId,
|
||||||
} catch (err) {
|
permissionOverwrites: [
|
||||||
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
{
|
||||||
}
|
id: interaction.user.id,
|
||||||
parentCategoryIdForTicket = parentId;
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||||
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({
|
id: CONFIG.ROLE_ID_TO_PING,
|
||||||
name: unclaimedName,
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||||
type: ChannelType.GuildText,
|
}
|
||||||
parent: parentId,
|
]
|
||||||
permissionOverwrites: [
|
});
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
} catch (err) {
|
||||||
{
|
console.error('guild.channels.create (ticket modal):', err);
|
||||||
id: interaction.user.id,
|
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
|
||||||
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}`;
|
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
|
||||||
|
|||||||
@@ -13,14 +13,12 @@ const {
|
|||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { getPriorityEmoji, replaceVariables } = require('../utils');
|
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 { sendTicketNotificationEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { getEmailRouting } = require('../services/guildSettings');
|
|
||||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
|
||||||
const { setNotifyDm } = require('../services/staffSettings');
|
const { setNotifyDm } = require('../services/staffSettings');
|
||||||
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
|
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
|
||||||
const { handleSetupCommand } = require('./setup');
|
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
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)
|
// 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;
|
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.
|
// /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate.
|
||||||
if (interaction.commandName === 'escalate') {
|
if (interaction.commandName === 'escalate') {
|
||||||
const reason = null;
|
const reason = null;
|
||||||
@@ -926,48 +891,38 @@ async function handleContextMenu(interaction) {
|
|||||||
|
|
||||||
let channel;
|
let channel;
|
||||||
let parentCategoryIdForTicket = null;
|
let parentCategoryIdForTicket = null;
|
||||||
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
let parentId;
|
||||||
try {
|
try {
|
||||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
|
parentId = await getOrCreateTicketCategory(
|
||||||
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
guild,
|
||||||
} catch (err) {
|
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||||
console.error('Discord ticket thread create (from message) failed:', err.message);
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
|
);
|
||||||
}
|
} catch (err) {
|
||||||
} else {
|
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
||||||
let parentId;
|
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
||||||
try {
|
}
|
||||||
parentId = await getOrCreateTicketCategory(
|
parentCategoryIdForTicket = parentId;
|
||||||
guild,
|
try {
|
||||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
channel = await guild.channels.create({
|
||||||
CONFIG.TICKET_CATEGORY_NAME
|
name: `ticket-${ticketNumber}`,
|
||||||
);
|
type: ChannelType.GuildText,
|
||||||
} catch (err) {
|
parent: parentId,
|
||||||
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
permissionOverwrites: [
|
||||||
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
}
|
{
|
||||||
parentCategoryIdForTicket = parentId;
|
id: message.author.id,
|
||||||
try {
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||||
channel = await guild.channels.create({
|
},
|
||||||
name: `ticket-${ticketNumber}`,
|
{
|
||||||
type: ChannelType.GuildText,
|
id: CONFIG.ROLE_ID_TO_PING,
|
||||||
parent: parentId,
|
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||||
permissionOverwrites: [
|
}
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
]
|
||||||
{
|
});
|
||||||
id: message.author.id,
|
} catch (err) {
|
||||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
console.error('guild.channels.create (context menu ticket):', err);
|
||||||
},
|
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
|
||||||
{
|
|
||||||
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}`;
|
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-')) {
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -88,7 +86,6 @@ async function handleDiscordReply(m) {
|
|||||||
m.content,
|
m.content,
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
subject,
|
subject,
|
||||||
discordUser,
|
|
||||||
msgId,
|
msgId,
|
||||||
m.author.id
|
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 }
|
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({
|
mongoose.model('StaffSettings', new mongoose.Schema({
|
||||||
userId: { type: String, required: true, unique: true },
|
userId: { type: String, required: true, unique: true },
|
||||||
guildId: { type: String, required: true },
|
guildId: { type: String, required: true },
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
|||||||
// Ticket settings
|
// Ticket settings
|
||||||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
|
'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',
|
'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
|
// Escalation categories
|
||||||
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
||||||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_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} replyText - Reply text
|
||||||
* @param {string} recipientEmail - Recipient email
|
* @param {string} recipientEmail - Recipient email
|
||||||
* @param {string} subject - Subject line
|
* @param {string} subject - Subject line
|
||||||
* @param {string} discordUser - Discord user name
|
|
||||||
* @param {string} messageId - Message ID (optional)
|
* @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(
|
async function sendGmailReply(
|
||||||
threadId,
|
threadId,
|
||||||
replyText,
|
replyText,
|
||||||
recipientEmail,
|
recipientEmail,
|
||||||
subject,
|
subject,
|
||||||
discordUser,
|
|
||||||
messageId,
|
messageId,
|
||||||
userId = null
|
userId = null
|
||||||
) {
|
) {
|
||||||
@@ -265,50 +263,44 @@ async function sendGmailReply(
|
|||||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||||
safeSubject
|
safeSubject
|
||||||
).toString('base64')}?=`;
|
).toString('base64')}?=`;
|
||||||
const safeUser = escapeHtml(discordUser);
|
|
||||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||||
const companySignatureText = (CONFIG.SIGNATURE || '').replace(/<br>/g, '\n');
|
|
||||||
|
|
||||||
// Get staff signature if userId provided
|
|
||||||
let signatureBlocks = { text: '', html: '' };
|
let signatureBlocks = { text: '', html: '' };
|
||||||
if (userId) {
|
if (userId) {
|
||||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
|
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
|
||||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||||
const safeStaffSigText = signatureBlocks.text;
|
const safeStaffSigText = signatureBlocks.text;
|
||||||
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
|
||||||
|
|
||||||
const htmlBody = `
|
const htmlBody = `
|
||||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||||
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
|
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
|
||||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
<div>
|
||||||
<table border="0" cellpadding="0" cellspacing="0">
|
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65" alt="Indifferent Broccoli"><br>` : ''}
|
||||||
<tr>
|
Indifferent Broccoli Support<br>
|
||||||
<td style="padding-right: 12px;">
|
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
||||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
||||||
</td>
|
<br>
|
||||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
<em>"We eat lag for breakfast. Whatever."</em>
|
||||||
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
|
</div>
|
||||||
<div style="color: #666; font-size: 12px;">${safeCompanySigHtml}</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const boundary = '000000000000' + Date.now().toString(16);
|
const boundary = '000000000000' + Date.now().toString(16);
|
||||||
|
|
||||||
const plainBody = [];
|
const plainBody = [];
|
||||||
plainBody.push(replyText);
|
plainBody.push(replyText);
|
||||||
if (safeStaffSigText) {
|
if (safeStaffSigText) {
|
||||||
plainBody.push(safeStaffSigText);
|
plainBody.push('');
|
||||||
}
|
plainBody.push(safeStaffSigText);
|
||||||
plainBody.push('');
|
}
|
||||||
plainBody.push('------------------------------');
|
plainBody.push('');
|
||||||
plainBody.push('');
|
plainBody.push('Indifferent Broccoli Support');
|
||||||
plainBody.push(companySignatureText);
|
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([
|
const raw = Buffer.from([
|
||||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
`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) {
|
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
||||||
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
let parentId;
|
||||||
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
try {
|
||||||
if (!parentChannel) {
|
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||||
throw new Error('Thread parent channel not found');
|
} 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({
|
let channel;
|
||||||
name: `🎫・ticket-${ticketNumber}`,
|
try {
|
||||||
autoArchiveDuration: 1440,
|
channel = await guild.channels.create({
|
||||||
type: ChannelType.PrivateThread,
|
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||||||
invitable: false,
|
type: ChannelType.GuildText,
|
||||||
reason: `Ticket #${ticketNumber}`
|
parent: parentId,
|
||||||
});
|
permissionOverwrites: [
|
||||||
|
{
|
||||||
await thread.members.add(userId);
|
id: guild.id,
|
||||||
// Add all members with the support role so they can see and reply in the thread
|
deny: [PermissionFlagsBits.ViewChannel]
|
||||||
if (CONFIG.ROLE_ID_TO_PING) {
|
},
|
||||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
{
|
||||||
if (role?.members?.size) {
|
id: userId,
|
||||||
for (const [memberId] of role.members) {
|
allow: [
|
||||||
if (memberId === userId) continue; // already added
|
PermissionFlagsBits.ViewChannel,
|
||||||
await thread.members.add(memberId).catch(() => {});
|
PermissionFlagsBits.SendMessages,
|
||||||
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CONFIG.ROLE_ID_TO_PING,
|
||||||
|
allow: [
|
||||||
|
PermissionFlagsBits.ViewChannel,
|
||||||
|
PermissionFlagsBits.SendMessages,
|
||||||
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
}
|
});
|
||||||
return thread;
|
} catch (e) {
|
||||||
} else {
|
console.error('guild.channels.create (createTicketChannel):', e);
|
||||||
let parentId;
|
throw e;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return channel;
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LIMITS & PERMISSIONS ---
|
// --- LIMITS & PERMISSIONS ---
|
||||||
@@ -649,8 +553,6 @@ module.exports = {
|
|||||||
getNextTicketNumber,
|
getNextTicketNumber,
|
||||||
getOrCreateTicketCategory,
|
getOrCreateTicketCategory,
|
||||||
cleanupEmptyOverflowCategory,
|
cleanupEmptyOverflowCategory,
|
||||||
createDiscordTicketAsThread,
|
|
||||||
createEmailTicketAsThread,
|
|
||||||
RENAME_WINDOW_MS,
|
RENAME_WINDOW_MS,
|
||||||
RENAME_LIMIT,
|
RENAME_LIMIT,
|
||||||
getSenderLocal,
|
getSenderLocal,
|
||||||
|
|||||||
Reference in New Issue
Block a user