staff notifications
This commit is contained in:
12
.claude/settings.local.json
Normal file
12
.claude/settings.local.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(node -e ':*)",
|
||||||
|
"Bash(node --check services/tickets.js)",
|
||||||
|
"Bash(node --check config.js)",
|
||||||
|
"Bash(node --check handlers/commands.js)",
|
||||||
|
"Bash(node --check handlers/buttons.js)",
|
||||||
|
"Bash(node --check gmail-poll.js)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,9 @@ AUTO_UNCLAIM_AFTER_HOURS=24
|
|||||||
ALLOW_CLAIM_OVERWRITE=false
|
ALLOW_CLAIM_OVERWRITE=false
|
||||||
STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated
|
STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated
|
||||||
CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry in STAFF_EMOJIS
|
CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry in STAFF_EMOJIS
|
||||||
|
ADMIN_ID= # Discord user ID of the bot admin (for /staffnotification)
|
||||||
|
STAFF_NOTIFICATION_CATEGORY_ID= # Category for staff notification channels (created by /notification add)
|
||||||
|
UNCLAIMED_REMINDER_THRESHOLDS=1,2,4 # Comma-separated hour thresholds for unclaimed ticket alerts
|
||||||
|
|
||||||
# --- Thread-style tickets (legacy) ---
|
# --- Thread-style tickets (legacy) ---
|
||||||
USE_THREADS=false
|
USE_THREADS=false
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const { handleDiscordReply } = require('./handlers/messages');
|
|||||||
|
|
||||||
// Services & jobs
|
// Services & jobs
|
||||||
const { sendTicketClosedEmail } = require('./services/gmail');
|
const { sendTicketClosedEmail } = require('./services/gmail');
|
||||||
const { checkAutoClose, checkReminders, checkAutoUnclaim } = require('./services/tickets');
|
const { checkAutoClose, checkAutoUnclaim } = require('./services/tickets');
|
||||||
|
const { notifyAllStaffUnclaimed } = require('./services/staffNotifications');
|
||||||
const { registerCommands } = require('./commands/register');
|
const { registerCommands } = require('./commands/register');
|
||||||
const bosscordRoutes = require('./routes/bosscord');
|
const bosscordRoutes = require('./routes/bosscord');
|
||||||
const { setBot } = require('./api/bosscordClient');
|
const { setBot } = require('./api/bosscordClient');
|
||||||
@@ -152,11 +153,9 @@ client.once('ready', async () => {
|
|||||||
console.log('✓ Auto-close enabled: checking every hour');
|
console.log('✓ Auto-close enabled: checking every hour');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CONFIG.REMINDER_ENABLED) {
|
setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000);
|
||||||
setInterval(() => checkReminders(client), 30 * 60 * 1000);
|
notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e));
|
||||||
checkReminders(client);
|
console.log('✓ Staff unclaimed reminders: checking every 30 minutes');
|
||||||
console.log('✓ Reminders enabled: checking every 30 minutes');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
|
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
|
||||||
setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000);
|
setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000);
|
||||||
|
|||||||
@@ -384,6 +384,52 @@ async function registerCommands() {
|
|||||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('notification')
|
||||||
|
.setDescription('Manage your staff notification settings')
|
||||||
|
.setContexts([InteractionContextType.Guild])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub
|
||||||
|
.setName('set')
|
||||||
|
.setDescription('Set your notification cooldown (hours between alerts per ticket)')
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt
|
||||||
|
.setName('hours')
|
||||||
|
.setDescription('Cooldown in hours (1–6)')
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(6)
|
||||||
|
.setRequired(true)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.addSubcommand(sub =>
|
||||||
|
sub
|
||||||
|
.setName('add')
|
||||||
|
.setDescription('Create a notification channel for a staff member')
|
||||||
|
.addUserOption(opt =>
|
||||||
|
opt.setName('member').setDescription('Staff member').setRequired(true)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName('staffnotification')
|
||||||
|
.setDescription('Override notification cooldown for another staff member (admin only)')
|
||||||
|
.setContexts([InteractionContextType.Guild])
|
||||||
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
|
||||||
|
.addUserOption(opt =>
|
||||||
|
opt.setName('member').setDescription('Staff member').setRequired(true)
|
||||||
|
)
|
||||||
|
.addIntegerOption(opt =>
|
||||||
|
opt
|
||||||
|
.setName('hours')
|
||||||
|
.setDescription('Cooldown in hours (1–6)')
|
||||||
|
.setMinValue(1)
|
||||||
|
.setMaxValue(6)
|
||||||
|
.setRequired(true)
|
||||||
|
),
|
||||||
|
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('accountinfo')
|
.setName('accountinfo')
|
||||||
.setDescription('Look up website account info by email or Discord user')
|
.setDescription('Look up website account info by email or Discord user')
|
||||||
|
|||||||
26
config.js
26
config.js
@@ -119,21 +119,7 @@ const CONFIG = {
|
|||||||
EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00,
|
EMBED_COLOR_CLAIMED: parseInt(process.env.EMBED_COLOR_CLAIMED) || 0xFFFF00,
|
||||||
EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600,
|
EMBED_COLOR_ESCALATED: parseInt(process.env.EMBED_COLOR_ESCALATED) || 0xFF6600,
|
||||||
EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124,
|
EMBED_COLOR_INFO: parseInt(process.env.EMBED_COLOR_INFO) || 0x1e2124,
|
||||||
STAFF_CATEGORIES: (() => {
|
STAFF_CATEGORIES: new Map(), // deprecated – kept for staffChannel.js compat
|
||||||
const raw = process.env.STAFF_CATEGORIES;
|
|
||||||
const map = new Map();
|
|
||||||
if (!raw || !String(raw).trim()) return map;
|
|
||||||
for (const part of String(raw).split(',')) {
|
|
||||||
const seg = part.trim();
|
|
||||||
if (!seg) continue;
|
|
||||||
const idx = seg.indexOf(':');
|
|
||||||
if (idx === -1) continue;
|
|
||||||
const userId = seg.slice(0, idx).trim();
|
|
||||||
const categoryId = seg.slice(idx + 1).trim();
|
|
||||||
if (userId && categoryId) map.set(userId, categoryId);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
})(),
|
|
||||||
STAFF_EMOJIS: (() => {
|
STAFF_EMOJIS: (() => {
|
||||||
const raw = process.env.STAFF_EMOJIS;
|
const raw = process.env.STAFF_EMOJIS;
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
@@ -150,10 +136,12 @@ const CONFIG = {
|
|||||||
return map;
|
return map;
|
||||||
})(),
|
})(),
|
||||||
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
||||||
STAFF_T1_CATEGORY: process.env.STAFF_T1_CATEGORY || null,
|
ADMIN_ID: process.env.ADMIN_ID || null,
|
||||||
STAFF_T2_CATEGORY: process.env.STAFF_T2_CATEGORY || null,
|
STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_ID || null,
|
||||||
STAFF_T3_CATEGORY: process.env.STAFF_T3_CATEGORY || null,
|
UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4')
|
||||||
UNCLAIMED_CATEGORY_ID: process.env.UNCLAIMED_CATEGORY_ID || null
|
.split(',')
|
||||||
|
.map(s => parseInt(s.trim(), 10))
|
||||||
|
.filter(n => !isNaN(n) && n > 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Ticket category tags for /tag set – [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */
|
/** Ticket category tags for /tag set – [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const {
|
|||||||
getFormattedDate
|
getFormattedDate
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
const { getGmailClient } = require('./services/gmail');
|
||||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread } = require('./services/tickets');
|
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||||
const { getEmailRouting } = require('./services/guildSettings');
|
const { getEmailRouting } = require('./services/guildSettings');
|
||||||
const { logError } = require('./services/debugLog');
|
const { logError } = require('./services/debugLog');
|
||||||
|
|
||||||
@@ -157,11 +157,9 @@ async function poll(client) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { local, number } = await getNextTicketNumber(sEmail);
|
const { number } = await getNextTicketNumber(sEmail);
|
||||||
const safeLocal = local
|
const creatorNickname = getSenderLocal(sEmail);
|
||||||
.replace(/[^a-z0-9-]/gi, '')
|
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||||||
.substring(0, 50);
|
|
||||||
const chanName = `ticket-${safeLocal}-${number}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const routing = await getEmailRouting(guild.id);
|
const routing = await getEmailRouting(guild.id);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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 { canRename, makeTicketName, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit, getSenderLocal } = require('../services/tickets');
|
const { canRename, makeTicketName, resolveCreatorNickname, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, 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 { setEmailRouting } = require('../services/guildSettings');
|
const { setEmailRouting } = require('../services/guildSettings');
|
||||||
@@ -144,16 +144,24 @@ async function handleButton(interaction) {
|
|||||||
if (currentTier >= 2) {
|
if (currentTier >= 2) {
|
||||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
return interaction.reply({ content: 'This ticket is already at tier 3 support.', ephemeral: true });
|
||||||
}
|
}
|
||||||
const choiceRow = new ActionRowBuilder().addComponents(
|
const escalateButtons = [];
|
||||||
|
if (currentTier < 1) {
|
||||||
|
escalateButtons.push(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId('escalate_to_tier2')
|
.setCustomId('escalate_to_tier2')
|
||||||
.setLabel('To Tier 2')
|
.setLabel('To Tier 2')
|
||||||
.setStyle(ButtonStyle.Secondary),
|
.setStyle(ButtonStyle.Secondary)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentTier < 2) {
|
||||||
|
escalateButtons.push(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
.setCustomId('escalate_to_tier3')
|
.setCustomId('escalate_to_tier3')
|
||||||
.setLabel('To Tier 3')
|
.setLabel('To Tier 3')
|
||||||
.setStyle(ButtonStyle.Secondary)
|
.setStyle(ButtonStyle.Secondary)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
const choiceRow = new ActionRowBuilder().addComponents(escalateButtons);
|
||||||
return interaction.reply({
|
return interaction.reply({
|
||||||
content: 'Escalate to which tier?',
|
content: 'Escalate to which tier?',
|
||||||
components: [choiceRow],
|
components: [choiceRow],
|
||||||
@@ -302,29 +310,12 @@ async function handleClaim(interaction, ticket) {
|
|||||||
|
|
||||||
// Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK)
|
// Resolve claimerEmoji from STAFF_EMOJIS map (fallback to CLAIMER_EMOJI_FALLBACK)
|
||||||
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
const claimerEmoji = CONFIG.STAFF_EMOJIS.get(interaction.user.id) || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||||
// Resolve creatorNickname: displayName for Discord tickets, senderLocal for email tickets
|
const creatorNickname = await resolveCreatorNickname(guild, freshTicket);
|
||||||
let creatorNickname;
|
|
||||||
if (freshTicket.gmailThreadId.startsWith('discord-')) {
|
|
||||||
const creatorUserId = freshTicket.gmailThreadId.split('-').pop();
|
|
||||||
try {
|
|
||||||
const creatorMember = await guild.members.fetch(creatorUserId);
|
|
||||||
creatorNickname = creatorMember.displayName;
|
|
||||||
} catch {
|
|
||||||
creatorNickname = freshTicket.senderEmail;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
creatorNickname = getSenderLocal(freshTicket.senderEmail);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renameInfo = await canRename(freshTicket);
|
const renameInfo = await canRename(freshTicket);
|
||||||
if (renameInfo.ok) {
|
if (renameInfo.ok) {
|
||||||
const newName = makeTicketName(
|
const state = freshTicket.escalated ? 'escalated-claimed' : 'claimed';
|
||||||
{ escalated: !!freshTicket.escalated, claimed: true },
|
const newName = makeTicketName(state, freshTicket, creatorNickname, claimerEmoji);
|
||||||
freshTicket,
|
|
||||||
guild,
|
|
||||||
claimerEmoji,
|
|
||||||
creatorNickname
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await enqueueRename(interaction.channel, newName);
|
await enqueueRename(interaction.channel, newName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -377,10 +368,12 @@ async function handleClaim(interaction, ticket) {
|
|||||||
freshTicket.claimerId = null;
|
freshTicket.claimerId = null;
|
||||||
freshTicket.staffChannelId = null;
|
freshTicket.staffChannelId = null;
|
||||||
|
|
||||||
|
const creatorNicknameUnclaim = await resolveCreatorNickname(guild, freshTicket);
|
||||||
|
const unclaimState = (freshTicket.escalationTier ?? 0) >= 1 ? 'escalated' : 'unclaimed';
|
||||||
const renameInfo = await canRename(freshTicket);
|
const renameInfo = await canRename(freshTicket);
|
||||||
if (renameInfo.ok) {
|
if (renameInfo.ok) {
|
||||||
try {
|
try {
|
||||||
await enqueueRename(interaction.channel, `ticket-${freshTicket.ticketNumber}`);
|
await enqueueRename(interaction.channel, makeTicketName(unclaimState, freshTicket, creatorNicknameUnclaim));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Rename error (unclaim):', e);
|
console.error('Rename error (unclaim):', e);
|
||||||
}
|
}
|
||||||
@@ -607,6 +600,9 @@ async function handleTicketModal(interaction) {
|
|||||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||||
|
|
||||||
|
const creatorNicknameModal = interaction.member?.displayName || interaction.user.username;
|
||||||
|
const unclaimedName = toDiscordSafeName(`unclaimed-${creatorNicknameModal}-${ticketNumber}`);
|
||||||
|
|
||||||
let channel;
|
let channel;
|
||||||
let parentCategoryIdForTicket = null;
|
let parentCategoryIdForTicket = null;
|
||||||
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||||
@@ -634,7 +630,7 @@ async function handleTicketModal(interaction) {
|
|||||||
parentCategoryIdForTicket = parentId;
|
parentCategoryIdForTicket = parentId;
|
||||||
try {
|
try {
|
||||||
channel = await guild.channels.create({
|
channel = await guild.channels.create({
|
||||||
name: `ticket-${ticketNumber}`,
|
name: unclaimedName,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
permissionOverwrites: [
|
permissionOverwrites: [
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const {
|
|||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
const { CONFIG, TICKET_TAGS } = require('../config');
|
||||||
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
|
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
|
||||||
const { canRename, makeTicketName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
const { canRename, makeTicketName, resolveCreatorNickname, getSenderLocal, toDiscordSafeName, getOrCreateTicketCategory, createDiscordTicketAsThread, 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 { getEmailRouting } = require('../services/guildSettings');
|
||||||
@@ -26,6 +26,7 @@ const { handleSetupCommand } = require('./setup');
|
|||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
const Tag = mongoose.model('Tag');
|
const Tag = mongoose.model('Tag');
|
||||||
const User = mongoose.model('User');
|
const User = mongoose.model('User');
|
||||||
|
const StaffNotification = mongoose.model('StaffNotification');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
||||||
@@ -66,20 +67,19 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
? (isDiscordTicket ? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID) : (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID))
|
? (isDiscordTicket ? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID) : (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID))
|
||||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||||
|
|
||||||
|
// Clear claim on escalation
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $set: { escalated: true, escalationTier: nextTier } }
|
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
|
||||||
);
|
);
|
||||||
ticket.escalated = true;
|
ticket.escalated = true;
|
||||||
ticket.escalationTier = nextTier;
|
ticket.escalationTier = nextTier;
|
||||||
|
ticket.claimedBy = null;
|
||||||
|
|
||||||
|
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||||
const renameInfo = await canRename(ticket);
|
const renameInfo = await canRename(ticket);
|
||||||
if (renameInfo.ok) {
|
if (renameInfo.ok) {
|
||||||
const newName = makeTicketName(
|
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
||||||
{ escalated: true, claimed: false },
|
|
||||||
ticket,
|
|
||||||
interaction.guild
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await enqueueRename(interaction.channel, newName);
|
await enqueueRename(interaction.channel, newName);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -97,19 +97,6 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
await enqueueMove(interaction.channel, categoryId);
|
await enqueueMove(interaction.channel, categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!interaction.channel.isThread()) {
|
|
||||||
try {
|
|
||||||
const emoji = nextTier === 1 ? CONFIG.PRIORITY_MEDIUM_EMOJI : CONFIG.PRIORITY_HIGH_EMOJI;
|
|
||||||
const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, '');
|
|
||||||
const renameInfoEsc = await canRename(ticket);
|
|
||||||
if (renameInfoEsc.ok) await enqueueRename(interaction.channel, `${emoji}${baseName}`);
|
|
||||||
const tierCategory = nextTier === 1 ? CONFIG.STAFF_T2_CATEGORY : CONFIG.STAFF_T3_CATEGORY;
|
|
||||||
if (tierCategory) await enqueueMove(interaction.channel, tierCategory);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Staff tier category (escalate):', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingEmbed = new EmbedBuilder()
|
const pendingEmbed = new EmbedBuilder()
|
||||||
.setDescription('Ticket will be escalated in a few seconds.')
|
.setDescription('Ticket will be escalated in a few seconds.')
|
||||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||||
@@ -188,20 +175,18 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
|
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null } }
|
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
|
||||||
);
|
);
|
||||||
ticket.escalated = newTier > 0;
|
ticket.escalated = newTier > 0;
|
||||||
ticket.escalationTier = newTier;
|
ticket.escalationTier = newTier;
|
||||||
|
ticket.claimedBy = null;
|
||||||
|
|
||||||
const baseName = interaction.channel.name.replace(/^[🟢🟡🔴]/, '');
|
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||||
|
const state = newTier === 0 ? 'unclaimed' : 'escalated';
|
||||||
const renameInfo = await canRename(ticket);
|
const renameInfo = await canRename(ticket);
|
||||||
if (renameInfo.ok) {
|
if (renameInfo.ok) {
|
||||||
try {
|
try {
|
||||||
const emoji = newTier === 0 ? CONFIG.PRIORITY_LOW_EMOJI : CONFIG.PRIORITY_MEDIUM_EMOJI;
|
await enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname));
|
||||||
await enqueueRename(
|
|
||||||
interaction.channel,
|
|
||||||
newTier === 0 ? baseName : `${emoji}${baseName}`
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Rename error (deescalate):', e);
|
console.error('Rename error (deescalate):', e);
|
||||||
}
|
}
|
||||||
@@ -215,8 +200,15 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
|
|
||||||
if (!interaction.channel.isThread()) {
|
if (!interaction.channel.isThread()) {
|
||||||
try {
|
try {
|
||||||
if (newTier === 0 && CONFIG.STAFF_T1_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T1_CATEGORY);
|
if (newTier === 0) {
|
||||||
if (newTier === 1 && CONFIG.STAFF_T2_CATEGORY) await enqueueMove(interaction.channel, CONFIG.STAFF_T2_CATEGORY);
|
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
|
||||||
|
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
|
||||||
|
} else if (newTier === 1) {
|
||||||
|
const t2Category = isDiscordTicket
|
||||||
|
? (CONFIG.DISCORD_ESCALATED2_CHANNEL_ID || CONFIG.DISCORD_ESCALATED_CATEGORY_ID)
|
||||||
|
: (CONFIG.EMAIL_ESCALATED2_CHANNEL_ID || CONFIG.EMAIL_ESCALATED_CATEGORY_ID);
|
||||||
|
if (t2Category) await enqueueMove(interaction.channel, t2Category);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Move error (deescalate):', e);
|
console.error('Move error (deescalate):', e);
|
||||||
}
|
}
|
||||||
@@ -328,6 +320,82 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /notification set | /notification add
|
||||||
|
if (interaction.commandName === 'notification') {
|
||||||
|
const sub = interaction.options.getSubcommand();
|
||||||
|
if (sub === 'set') {
|
||||||
|
const hours = interaction.options.getInteger('hours');
|
||||||
|
try {
|
||||||
|
await StaffNotification.findOneAndUpdate(
|
||||||
|
{ userId: interaction.user.id },
|
||||||
|
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
return interaction.reply({ content: `Notification cooldown set to ${hours} hour(s).`, ephemeral: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('notification set error:', err);
|
||||||
|
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sub === 'add') {
|
||||||
|
if (!CONFIG.STAFF_NOTIFICATION_CATEGORY_ID) {
|
||||||
|
return interaction.reply({ content: 'STAFF_NOTIFICATION_CATEGORY_ID is not configured.', ephemeral: true });
|
||||||
|
}
|
||||||
|
const member = interaction.options.getMember('member');
|
||||||
|
if (!member) {
|
||||||
|
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
|
||||||
|
}
|
||||||
|
const displayName = member.displayName;
|
||||||
|
const emoji = CONFIG.STAFF_EMOJIS.get(member.id) || '';
|
||||||
|
const chanName = toDiscordSafeName(`${displayName}${emoji}`);
|
||||||
|
try {
|
||||||
|
const newChannel = await interaction.guild.channels.create({
|
||||||
|
name: chanName,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: CONFIG.STAFF_NOTIFICATION_CATEGORY_ID,
|
||||||
|
permissionOverwrites: [
|
||||||
|
{ id: interaction.guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
|
{ id: member.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] },
|
||||||
|
...(CONFIG.ROLE_ID_TO_PING ? [{ id: CONFIG.ROLE_ID_TO_PING, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory] }] : [])
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await StaffNotification.findOneAndUpdate(
|
||||||
|
{ userId: member.id },
|
||||||
|
{ $set: { channelId: newChannel.id, guildId: interaction.guild.id, updatedAt: new Date() } },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
return interaction.reply({ content: `Notification channel created: ${newChannel}`, ephemeral: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('notification add error:', err);
|
||||||
|
return interaction.reply({ content: 'Failed to create notification channel.', ephemeral: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /staffnotification (admin only)
|
||||||
|
if (interaction.commandName === 'staffnotification') {
|
||||||
|
if (interaction.user.id !== CONFIG.ADMIN_ID) {
|
||||||
|
return interaction.reply({ content: 'This command is restricted to the bot admin.', ephemeral: true });
|
||||||
|
}
|
||||||
|
const member = interaction.options.getMember('member');
|
||||||
|
const hours = interaction.options.getInteger('hours');
|
||||||
|
if (!member) {
|
||||||
|
return interaction.reply({ content: 'Could not resolve that member.', ephemeral: true });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await StaffNotification.findOneAndUpdate(
|
||||||
|
{ userId: member.id },
|
||||||
|
{ $set: { cooldownHours: hours, updatedAt: new Date() } },
|
||||||
|
{ upsert: true }
|
||||||
|
);
|
||||||
|
return interaction.reply({ content: `Notification cooldown for ${member.displayName} set to ${hours} hour(s).`, ephemeral: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('staffnotification error:', err);
|
||||||
|
return interaction.reply({ content: 'Failed to update notification setting.', ephemeral: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (interaction.commandName === 'notifydm') {
|
if (interaction.commandName === 'notifydm') {
|
||||||
try {
|
try {
|
||||||
const setting = interaction.options.getString('setting') === 'on';
|
const setting = interaction.options.getString('setting') === 'on';
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
|||||||
const { updateTicketActivity } = require('../services/tickets');
|
const { updateTicketActivity } = require('../services/tickets');
|
||||||
const { getNotifyDm } = require('../services/staffSettings');
|
const { getNotifyDm } = require('../services/staffSettings');
|
||||||
const { pingStaffChannel } = require('../services/staffChannel');
|
const { pingStaffChannel } = require('../services/staffChannel');
|
||||||
|
const { notifyStaffOfReply } = require('../services/staffNotifications');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
|
||||||
@@ -43,6 +44,19 @@ async function handleDiscordReply(m) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify claiming staff if a non-staff user replied (works for both Discord and email tickets)
|
||||||
|
if (ticket.claimerId) {
|
||||||
|
const guild = m.guild;
|
||||||
|
const member = await guild.members.fetch(m.author.id).catch(() => null);
|
||||||
|
const isStaff = member && CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
|
||||||
|
if (!isStaff) {
|
||||||
|
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||||
|
if (freshTicket) {
|
||||||
|
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const discordUser = m.member?.displayName || m.author.username;
|
const discordUser = m.member?.displayName || m.author.username;
|
||||||
|
|
||||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||||
|
|||||||
11
models.js
11
models.js
@@ -815,7 +815,8 @@ mongoose.model('Ticket', new mongoose.Schema({
|
|||||||
welcomeMessageId: String,
|
welcomeMessageId: String,
|
||||||
claimerId: String,
|
claimerId: String,
|
||||||
staffChannelId: String,
|
staffChannelId: String,
|
||||||
parentCategoryId: String
|
parentCategoryId: String,
|
||||||
|
unclaimedReminderssent: { type: [Number], default: [] }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mongoose.model('TicketCounter', new mongoose.Schema({
|
mongoose.model('TicketCounter', new mongoose.Schema({
|
||||||
@@ -856,3 +857,11 @@ mongoose.model('StaffSettings', new mongoose.Schema({
|
|||||||
notifyDm: { type: Boolean, default: false },
|
notifyDm: { type: Boolean, default: false },
|
||||||
updatedAt: { type: Date, default: Date.now }
|
updatedAt: { type: Date, default: Date.now }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
mongoose.model('StaffNotification', new mongoose.Schema({
|
||||||
|
userId: { type: String, required: true, unique: true },
|
||||||
|
guildId: String,
|
||||||
|
channelId: String,
|
||||||
|
cooldownHours: { type: Number, default: 1 },
|
||||||
|
updatedAt: { type: Date, default: Date.now }
|
||||||
|
}));
|
||||||
|
|||||||
109
services/staffNotifications.js
Normal file
109
services/staffNotifications.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Staff notification service – reply alerts and unclaimed ticket reminders.
|
||||||
|
*
|
||||||
|
* notifyStaffOfReply: posts in the claimer's notification channel when a
|
||||||
|
* non-staff user replies, respecting a per-staff cooldown.
|
||||||
|
*
|
||||||
|
* notifyAllStaffUnclaimed: background job that checks unclaimed tickets
|
||||||
|
* against configurable hour thresholds and posts one alert per threshold
|
||||||
|
* per ticket (highest newly-crossed threshold only).
|
||||||
|
*/
|
||||||
|
const { mongoose } = require('../db-connection');
|
||||||
|
const { CONFIG } = require('../config');
|
||||||
|
|
||||||
|
const Ticket = mongoose.model('Ticket');
|
||||||
|
const StaffNotification = mongoose.model('StaffNotification');
|
||||||
|
|
||||||
|
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
|
||||||
|
const replyCooldowns = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify the claiming staff member when a non-staff user replies.
|
||||||
|
* Respects the staff member's cooldownHours setting (default 1h).
|
||||||
|
* Posts in their notification channel if one exists.
|
||||||
|
*/
|
||||||
|
async function notifyStaffOfReply(guild, ticket, message) {
|
||||||
|
if (!ticket.claimerId) return;
|
||||||
|
|
||||||
|
const staffRecord = await StaffNotification.findOne({ userId: ticket.claimerId }).lean();
|
||||||
|
if (!staffRecord?.channelId) return;
|
||||||
|
|
||||||
|
const cooldownMs = (staffRecord.cooldownHours || 1) * 60 * 60 * 1000;
|
||||||
|
const cooldownKey = `${ticket.claimerId}:${ticket.gmailThreadId}`;
|
||||||
|
const lastNotified = replyCooldowns.get(cooldownKey) || 0;
|
||||||
|
if (Date.now() - lastNotified < cooldownMs) return;
|
||||||
|
|
||||||
|
const notifChannel = await guild.channels.fetch(staffRecord.channelId).catch(() => null);
|
||||||
|
if (!notifChannel) return;
|
||||||
|
|
||||||
|
const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`;
|
||||||
|
const snippet = message.content?.slice(0, 300) || '(no text)';
|
||||||
|
await notifChannel.send(
|
||||||
|
`New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})`
|
||||||
|
);
|
||||||
|
|
||||||
|
replyCooldowns.set(cooldownKey, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background job: check all open unclaimed tickets against hour thresholds.
|
||||||
|
* For each ticket, find the highest threshold that has been crossed but not
|
||||||
|
* yet recorded. Post one notification per ticket per run (the highest new
|
||||||
|
* threshold) into every staff notification channel.
|
||||||
|
*/
|
||||||
|
async function notifyAllStaffUnclaimed(client) {
|
||||||
|
const thresholds = CONFIG.UNCLAIMED_REMINDER_THRESHOLDS;
|
||||||
|
if (!thresholds || thresholds.length === 0) return;
|
||||||
|
|
||||||
|
const sorted = [...thresholds].sort((a, b) => a - b);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const unclaimedTickets = await Ticket.find({
|
||||||
|
status: 'open',
|
||||||
|
claimedBy: null,
|
||||||
|
createdAt: { $ne: null }
|
||||||
|
}).lean();
|
||||||
|
|
||||||
|
if (unclaimedTickets.length === 0) return;
|
||||||
|
|
||||||
|
const staffRecords = await StaffNotification.find({ channelId: { $ne: null } }).lean();
|
||||||
|
if (staffRecords.length === 0) return;
|
||||||
|
|
||||||
|
const guild = CONFIG.DISCORD_GUILD_ID
|
||||||
|
? client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID)
|
||||||
|
: client.guilds.cache.first();
|
||||||
|
if (!guild) return;
|
||||||
|
|
||||||
|
for (const ticket of unclaimedTickets) {
|
||||||
|
const ageMs = now - new Date(ticket.createdAt).getTime();
|
||||||
|
const ageHours = ageMs / (60 * 60 * 1000);
|
||||||
|
const alreadySent = ticket.unclaimedReminderssent || [];
|
||||||
|
|
||||||
|
// Find thresholds crossed but not yet sent
|
||||||
|
const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t));
|
||||||
|
if (crossedNew.length === 0) continue;
|
||||||
|
|
||||||
|
// Only send the highest newly-crossed threshold
|
||||||
|
const highest = crossedNew[crossedNew.length - 1];
|
||||||
|
|
||||||
|
const channelName = ticket.discordThreadId
|
||||||
|
? `<#${ticket.discordThreadId}>`
|
||||||
|
: `ticket #${ticket.ticketNumber}`;
|
||||||
|
const hoursAgo = Math.floor(ageHours);
|
||||||
|
const alertMsg = `Unclaimed ticket alert: ${channelName} has been unclaimed for ${hoursAgo}+ hour(s) (${highest}h threshold).`;
|
||||||
|
|
||||||
|
for (const rec of staffRecords) {
|
||||||
|
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
|
||||||
|
if (chan) {
|
||||||
|
await chan.send(alertMsg).catch(e => console.error('Unclaimed notify send:', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Ticket.updateOne(
|
||||||
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
|
{ $addToSet: { unclaimedReminderssent: highest } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { notifyStaffOfReply, notifyAllStaffUnclaimed };
|
||||||
@@ -43,20 +43,47 @@ function toDiscordSafeName(str) {
|
|||||||
.slice(0, 100);
|
.slice(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// claimerEmoji and creatorNickname are only used in the claimed branch.
|
/**
|
||||||
// Callers that do not pass them (e.g. escalation rename) get the unclaimed name as before.
|
* Resolve a human-friendly creator nickname for channel naming.
|
||||||
function makeTicketName({ escalated, claimed }, ticket, guild, claimerEmoji, creatorNickname) {
|
* Discord tickets: guild member displayName. Email tickets: senderLocal.
|
||||||
const senderLocal = getSenderLocal(ticket.senderEmail);
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {object} ticket
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function resolveCreatorNickname(guild, ticket) {
|
||||||
|
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||||
|
const creatorUserId = ticket.gmailThreadId.split('-').pop();
|
||||||
|
try {
|
||||||
|
const member = await guild.members.fetch(creatorUserId);
|
||||||
|
return member.displayName;
|
||||||
|
} catch {
|
||||||
|
return getSenderLocal(ticket.senderEmail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getSenderLocal(ticket.senderEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a channel name from ticket state.
|
||||||
|
* @param {'unclaimed'|'claimed'|'escalated'|'escalated-claimed'} state
|
||||||
|
* @param {object} ticket
|
||||||
|
* @param {string} creatorNickname - pre-resolved via resolveCreatorNickname
|
||||||
|
* @param {string} [claimerEmoji] - required for claimed / escalated-claimed
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
|
||||||
const num = ticket.ticketNumber || 1;
|
const num = ticket.ticketNumber || 1;
|
||||||
if (escalated) {
|
switch (state) {
|
||||||
return (claimed && claimerEmoji && creatorNickname)
|
case 'claimed':
|
||||||
? toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`)
|
|
||||||
: `escalated-ticket-${senderLocal}-${num}`;
|
|
||||||
}
|
|
||||||
if (claimed && claimerEmoji && creatorNickname) {
|
|
||||||
return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
|
return toDiscordSafeName(`${claimerEmoji}-${creatorNickname}-${num}`);
|
||||||
|
case 'escalated':
|
||||||
|
return toDiscordSafeName(`escalated-${creatorNickname}-${num}`);
|
||||||
|
case 'escalated-claimed':
|
||||||
|
return toDiscordSafeName(`e-${claimerEmoji}-${creatorNickname}-${num}`);
|
||||||
|
case 'unclaimed':
|
||||||
|
default:
|
||||||
|
return toDiscordSafeName(`unclaimed-${creatorNickname}-${num}`);
|
||||||
}
|
}
|
||||||
return `ticket-${senderLocal}-${num}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function canRename(ticket) {
|
async function canRename(ticket) {
|
||||||
@@ -261,7 +288,7 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTicketChannel(guild, ticketNumber, userId, subject) {
|
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
||||||
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
||||||
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
||||||
if (!parentChannel) {
|
if (!parentChannel) {
|
||||||
@@ -300,7 +327,7 @@ async function createTicketChannel(guild, ticketNumber, userId, subject) {
|
|||||||
let channel;
|
let channel;
|
||||||
try {
|
try {
|
||||||
channel = await guild.channels.create({
|
channel = await guild.channels.create({
|
||||||
name: `ticket-${ticketNumber}`,
|
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||||||
type: ChannelType.GuildText,
|
type: ChannelType.GuildText,
|
||||||
parent: parentId,
|
parent: parentId,
|
||||||
permissionOverwrites: [
|
permissionOverwrites: [
|
||||||
@@ -554,6 +581,8 @@ module.exports = {
|
|||||||
RENAME_WINDOW_MS,
|
RENAME_WINDOW_MS,
|
||||||
RENAME_LIMIT,
|
RENAME_LIMIT,
|
||||||
getSenderLocal,
|
getSenderLocal,
|
||||||
|
toDiscordSafeName,
|
||||||
|
resolveCreatorNickname,
|
||||||
makeTicketName,
|
makeTicketName,
|
||||||
canRename,
|
canRename,
|
||||||
minutesFromMs,
|
minutesFromMs,
|
||||||
|
|||||||
Reference in New Issue
Block a user