more mvp strip

This commit is contained in:
2026-04-21 17:24:03 +00:00
parent 6d579207f3
commit f3ee27ed7a
17 changed files with 598 additions and 1088 deletions

View File

@@ -13,8 +13,6 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs
# Ticket creation: set one or both; /panel and /email-routing choose behavior
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
TICKET_CATEGORY_ID= # Category for email-originated ticket channels
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
TICKET_CATEGORY_NAME=Open Tickets
@@ -137,10 +135,6 @@ SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (
INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally)
INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string)
# --- Thread-style tickets (legacy) ---
USE_THREADS=false
THREAD_PARENT_CHANNEL=
# --- Game list (comma-separated; used for detection and tags) ---
GAME_LIST=Project Zomboid, Minecraft, ...

View File

@@ -11,7 +11,6 @@ const { mongoose } = require('./db-connection');
// Handlers
const { handleButton, handleTicketModal } = require('./handlers/buttons');
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup');
const { handleDiscordReply } = require('./handlers/messages');
// Services & jobs
@@ -113,30 +112,10 @@ async function runHandler(name, interaction, fn) {
}
client.on('interactionCreate', async interaction => {
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
try {
const handled = await handleSetupButton(interaction);
if (handled) return;
} catch (err) {
console.error('Setup button error:', err);
logError('handleSetupButton', err, null, client).catch(() => {});
await interaction.reply({
content: `Setup error: ${err.message}. Try \`/setup\` again.`,
ephemeral: true
}).catch(() => {});
return;
}
}
if (interaction.isButton()) {
return runHandler('handleButton', interaction, () => handleButton(interaction));
}
if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) {
const handled = await runHandler('handleSetupModal', interaction, () => handleSetupModal(interaction));
if (handled) return;
}
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
// Handle signature modal submit
try {
@@ -176,11 +155,6 @@ client.on('interactionCreate', async interaction => {
return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction));
}
if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) {
const handled = await runHandler('handleSetupSelect', interaction, () => handleSetupSelect(interaction));
if (handled) return;
}
if (interaction.isChatInputCommand()) {
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
}

View File

@@ -205,13 +205,6 @@ async function registerCommands() {
InteractionContextType.PrivateChannel
]),
new SlashCommandBuilder()
.setName('setup')
.setDescription('Run the panel setup wizard (name, support role, category, transcript channel, panel channel)')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
new SlashCommandBuilder()
.setName('panel')
.setDescription('Create a ticket panel for users to open Discord tickets')
@@ -253,13 +246,6 @@ async function registerCommands() {
.setRequired(false)
),
new SlashCommandBuilder()
.setName('email-routing')
.setDescription('Switch where new email tickets are created: threads or category channels')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
new SlashCommandBuilder()
.setName('notifydm')
.setDescription('Toggle DM notifications when your ticket receives a customer reply.')

View File

@@ -53,12 +53,12 @@ const CONFIG = {
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
LOGO_URL: process.env.LOGO_URL,
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
STAFF_EMOJIS: Object.fromEntries((process.env.STAFF_EMOJIS||'').split(',').map(s=>s.trim()).filter(Boolean).map(p=>{const i=p.indexOf(':');return i===-1?null:[p.slice(0,i).trim(),p.slice(i+1).trim()];}).filter(Boolean)),
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
GAME_LIST: process.env.GAME_LIST || '',
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null,
// Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming).
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_ESCALATED2_CHANNEL_ID || null,
@@ -96,8 +96,6 @@ const CONFIG = {
AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
USE_THREADS: process.env.USE_THREADS === 'true',
THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null,
BUTTON_LABEL_CLOSE: process.env.BUTTON_LABEL_CLOSE || 'Close Ticket',
BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim',
BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim',

0
git
View File

View File

@@ -20,8 +20,7 @@ const {
sanitizeEmbedText
} = require('./utils');
const { getGmailClient } = require('./services/gmail');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { getEmailRouting } = require('./services/guildSettings');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError, logGmail, logAutomation } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue');
@@ -192,27 +191,21 @@ async function poll(client) {
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
try {
const routing = await getEmailRouting(guild.id);
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null;
} else {
const parentId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
parentCategoryIdForTicket = parentId;
try {
ticketChan = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentId
});
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
throw createErr;
}
const parentId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
parentCategoryIdForTicket = parentId;
try {
ticketChan = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentId
});
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
throw createErr;
}
} catch (err) {
console.error('Channel create error (payload):', {

View File

@@ -16,11 +16,10 @@ const {
} = require('discord.js');
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils');
const { setEmailRouting } = require('../services/guildSettings');
const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands');
const { pendingCloses } = require('./pendingCloses');
@@ -78,26 +77,6 @@ async function handleButton(interaction) {
return await interaction.showModal(modal);
}
// --- Email routing (no ticket required) ---
if (interaction.customId === 'email_routing_thread' || interaction.customId === 'email_routing_category') {
const value = interaction.customId === 'email_routing_thread' ? 'thread' : 'category';
try {
await setEmailRouting(interaction.guild.id, value);
const label = value === 'thread' ? '**threads**' : '**channels in a category**';
await interaction.reply({
content: `Done. New email tickets will now be created as ${label}.`,
ephemeral: true
});
} catch (err) {
logError('email-routing-button', err, interaction).catch(() => {});
await interaction.reply({
content: 'Failed to update email routing.',
ephemeral: true
}).catch(() => {});
}
return;
}
// --- Ticket-scoped buttons (need ticket lookup) ---
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
@@ -339,7 +318,7 @@ async function handleClaim(interaction, ticket) {
freshTicket.claimedBy = claimerLabel;
freshTicket.claimerId = interaction.user.id;
const claimerEmoji = '🎫';
const claimerEmoji = CONFIG.STAFF_EMOJIS[interaction.user.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
@@ -590,10 +569,6 @@ async function handleTicketModal(interaction) {
const subject = game ? `[${game}] ${description.slice(0, 60)}` : description.slice(0, 80);
const priority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
const useThread =
interaction.customId === 'ticket_modal_thread' ||
(interaction.customId === 'ticket_modal' && !!CONFIG.DISCORD_THREAD_CHANNEL_ID);
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
if (!rateLimit.allowed) {
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
@@ -610,51 +585,39 @@ async function handleTicketModal(interaction) {
let channel;
let parentCategoryIdForTicket = null;
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
} catch (err) {
console.error('Discord ticket thread create failed:', err.message);
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
}
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
} else {
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (ticket modal):', err);
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
channel = await guild.channels.create({
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: interaction.user.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (ticket modal):', err);
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
}
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (ticket modal):', err);
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
// TODO(queue-migrate): initial permissionOverwrites here are fine since the channel is just being created, but any later permissionOverwrites mutation on this channel should go through channelQueue.
channel = await guild.channels.create({
name: unclaimedName,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: interaction.user.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (ticket modal):', err);
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
}
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;

View File

@@ -13,14 +13,12 @@ const {
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { getPriorityEmoji, replaceVariables } = require('../utils');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, checkTicketCreationRateLimit } = require('../services/tickets');
const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents');
const { getEmailRouting } = require('../services/guildSettings');
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
const { setNotifyDm } = require('../services/staffSettings');
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
const { handleSetupCommand } = require('./setup');
const { pendingCloses } = require('./pendingCloses');
const Ticket = mongoose.model('Ticket');
@@ -221,39 +219,6 @@ async function handleCommand(interaction) {
// Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES)
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
// /setup
if (interaction.commandName === 'setup') {
return handleSetupCommand(interaction);
}
// /email-routing switch where new email tickets are created (thread vs category)
if (interaction.commandName === 'email-routing') {
await interaction.deferReply({ ephemeral: true });
try {
const current = await getEmailRouting(interaction.guild.id);
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('email_routing_thread')
.setLabel('Threads')
.setStyle(ButtonStyle.Primary)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId('email_routing_category')
.setLabel('Category channels')
.setStyle(ButtonStyle.Primary)
.setEmoji('📁')
);
await interaction.editReply({
content: `Email ticket routing: **${current}**. Choose where new email tickets should be created:`,
components: [row]
});
} catch (err) {
logError('email-routing-command', err, interaction).catch(() => {});
await interaction.editReply('Failed to load routing options.').catch(() => {});
}
return;
}
// /escalate (tier 2 or 3 via level; works for both email and Discord). Always unclaims on escalate.
if (interaction.commandName === 'escalate') {
const reason = null;
@@ -926,48 +891,38 @@ async function handleContextMenu(interaction) {
let channel;
let parentCategoryIdForTicket = null;
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
try {
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
} catch (err) {
console.error('Discord ticket thread create (from message) failed:', err.message);
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
}
} else {
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (context menu ticket):', err);
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: message.author.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (context menu ticket):', err);
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
}
let parentId;
try {
parentId = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (context menu ticket):', err);
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
}
parentCategoryIdForTicket = parentId;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: message.author.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
});
} catch (err) {
console.error('guild.channels.create (context menu ticket):', err);
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
}
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;

View File

@@ -43,8 +43,6 @@ async function handleDiscordReply(m) {
}
}
const discordUser = m.member?.displayName || m.author.username;
if (ticket.gmailThreadId.startsWith('discord-')) {
return;
}
@@ -88,7 +86,6 @@ async function handleDiscordReply(m) {
m.content,
recipientEmail,
subject,
discordUser,
msgId,
m.author.id
);

View 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 };

View File

@@ -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 14)
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
};

View File

@@ -54,12 +54,6 @@ mongoose.model('Tag', new mongoose.Schema({
useCount: { type: Number, default: 0 }
}));
mongoose.model('GuildSettings', new mongoose.Schema({
guildId: { type: String, required: true, unique: true },
emailRouting: { type: String, enum: ['thread', 'category'], default: 'category' },
updatedAt: { type: Date, default: Date.now }
}));
mongoose.model('StaffSettings', new mongoose.Schema({
userId: { type: String, required: true, unique: true },
guildId: { type: String, required: true },

View File

@@ -21,7 +21,6 @@ const ALLOWED_CONFIG_KEYS = new Set([
// Ticket settings
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
// Escalation categories
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',

View File

@@ -239,16 +239,14 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
* @param {string} replyText - Reply text
* @param {string} recipientEmail - Recipient email
* @param {string} subject - Subject line
* @param {string} discordUser - Discord user name
* @param {string} messageId - Message ID (optional)
* @param {string} userId - Discord user ID for signature (optional)
* @param {string} userId - Discord user ID for optional personal valediction/tagline (optional)
*/
async function sendGmailReply(
threadId,
replyText,
recipientEmail,
subject,
discordUser,
messageId,
userId = null
) {
@@ -265,50 +263,44 @@ async function sendGmailReply(
const utf8Subject = `=?utf-8?B?${Buffer.from(
safeSubject
).toString('base64')}?=`;
const safeUser = escapeHtml(discordUser);
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
const companySignatureText = (CONFIG.SIGNATURE || '').replace(/<br>/g, '\n');
// Get staff signature if userId provided
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = signatureBlocks.text;
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 12px;">
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
</td>
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
<div style="color: #666; font-size: 12px;">${safeCompanySigHtml}</div>
</td>
</tr>
</table>
<div>
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65" alt="Indifferent Broccoli"><br>` : ''}
Indifferent Broccoli Support<br>
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
<br>
<em>"We eat lag for breakfast. Whatever."</em>
</div>
</div>`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(replyText);
if (safeStaffSigText) {
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push('------------------------------');
plainBody.push('');
plainBody.push(companySignatureText);
plainBody.push(replyText);
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push('Indifferent Broccoli Support');
plainBody.push('https://indifferentbroccoli.com/');
plainBody.push('Join us on Discord: https://discord.gg/2vmfrrtvJY');
plainBody.push('');
plainBody.push('"We eat lag for breakfast. Whatever."');
const raw = Buffer.from([
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,

View 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
};

View File

@@ -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 };

View File

@@ -273,145 +273,49 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
}
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
if (!parentChannel) {
throw new Error('Thread parent channel not found');
}
let parentId;
try {
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
} catch (e) {
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
throw new Error('Ticket category not found or could not be allocated');
}
const thread = await parentChannel.threads.create({
name: `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
await thread.members.add(userId);
// Add all members with the support role so they can see and reply in the thread
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
if (memberId === userId) continue; // already added
await thread.members.add(memberId).catch(() => {});
let channel;
try {
channel = await guild.channels.create({
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: userId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}
}
}
return thread;
} else {
let parentId;
try {
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
} catch (e) {
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
throw new Error('Ticket category not found or could not be allocated');
}
let channel;
try {
channel = await guild.channels.create({
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: userId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}
]
});
} catch (e) {
console.error('guild.channels.create (createTicketChannel):', e);
throw e;
}
return channel;
]
});
} catch (e) {
console.error('guild.channels.create (createTicketChannel):', e);
throw e;
}
}
/**
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
* Adds creator and all members with ROLE_ID_TO_PING.
* @param {import('discord.js').Guild} guild
* @param {number} ticketNumber
* @param {string} creatorUserId
* @returns {Promise<import('discord.js').ThreadChannel>}
*/
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
const parentChannel = guild.channels.cache.get(parentId);
if (!parentChannel) throw new Error('Discord thread parent channel not found');
const thread = await parentChannel.threads.create({
name: `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
await thread.members.add(creatorUserId);
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
if (memberId === creatorUserId) continue;
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
}
/**
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
* @param {import('discord.js').Guild} guild
* @param {number} ticketNumber
* @param {string} chanName
* @returns {Promise<import('discord.js').ThreadChannel>}
*/
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
const parentChannel = guild.channels.cache.get(parentId);
if (!parentChannel) throw new Error('Email thread parent channel not found');
const thread = await parentChannel.threads.create({
name: chanName || `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
return channel;
}
// --- LIMITS & PERMISSIONS ---
@@ -649,8 +553,6 @@ module.exports = {
getNextTicketNumber,
getOrCreateTicketCategory,
cleanupEmptyOverflowCategory,
createDiscordTicketAsThread,
createEmailTicketAsThread,
RENAME_WINDOW_MS,
RENAME_LIMIT,
getSenderLocal,