simplify: prune dead code, dedup gmail send, drop neutered log stubs

- Remove no-op log stubs (logGmail, logAutomation, logSecurity, logSystem)
  and ~17 callsites; dead counters in tickets.js and gmail-poll.js go too
- Dedup three near-identical Gmail send paths into sendThreadedEmail helper
- Drop dead Mongoose fields: broccoliniTicketId, lastSyncedBroccoliniArticleId,
  renameCount, renameWindowStart, reminderSent, staffChannelId,
  unclaimedRemindersSent, lastMessageAuthorIsStaff
- Drop dead config fields and their .env.example entries
- Inline api/botClient.js (3-line wrapper, 2 callers)
- Trim unused exports across utils.js, tickets.js, configSchema.js, debugLog.js
- Fix handlers/messages.js to use isStaff() — old partial check ignored
  ADDITIONAL_STAFF_ROLES, so those members were treated as customers
- Drop unused deps p-queue + dotenv-expand; move mongodb to devDependencies

Net: -583 LOC source + -57 LOC lockfile. All 23 modules load clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:37:14 +00:00
parent d5547e5eea
commit 840b6bfcf8
18 changed files with 165 additions and 805 deletions

View File

@@ -14,10 +14,8 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
TICKET_CATEGORY_ID= # Category for email-originated ticket channels TICKET_CATEGORY_ID= # Category for email-originated ticket channels
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.) # Category display name (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
TICKET_CATEGORY_NAME=Open Tickets TICKET_CATEGORY_NAME=Open Tickets
TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
# Escalation categories (tier 2 and tier 3) # Escalation categories (tier 2 and tier 3)
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord) DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
@@ -32,7 +30,6 @@ ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also
TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
DISCORD_CHANNEL_ID= # General Discord channel (if used)
# --- Discord: Ticket copy & buttons --- # --- Discord: Ticket copy & buttons ---
# ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME # ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME
@@ -50,8 +47,7 @@ GOOGLE_CLIENT_SECRET= # OAuth2 Client Secret
REFRESH_TOKEN= # OAuth2 refresh token for the support inbox REFRESH_TOKEN= # OAuth2 refresh token for the support inbox
MY_EMAIL= # Support inbox email address MY_EMAIL= # Support inbox email address
# --- Server & URLs --- # --- Server ---
NGROK_URL= # Public URL (optional); run ngrok outside this repo
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only) # HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
@@ -74,7 +70,6 @@ DISCORD_AUTO_CLOSE_MESSAGE= # Message in ticket when auto-closed (e.g. ...
# --- Ticket limits & permissions --- # --- Ticket limits & permissions ---
GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally
TICKET_LIMIT_PER_CATEGORY=3 # Max tickets per category
RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disabled) RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disabled)
RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit
BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets
@@ -83,7 +78,6 @@ ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissi
# --- Auto-close --- # --- Auto-close ---
AUTO_CLOSE_ENABLED=false AUTO_CLOSE_ENABLED=false
AUTO_CLOSE_AFTER_HOURS=72 AUTO_CLOSE_AFTER_HOURS=72
AUTO_CLOSE_MESSAGE= # Message when ticket is auto-closed
# --- Reminders --- # --- Reminders ---
REMINDER_ENABLED=false REMINDER_ENABLED=false
@@ -140,7 +134,6 @@ GAME_LIST=Project Zomboid, Minecraft, ...
# --- Embed colors (hex with 0x prefix) --- # --- Embed colors (hex with 0x prefix) ---
EMBED_COLOR_OPEN=0x00FF00 EMBED_COLOR_OPEN=0x00FF00
EMBED_COLOR_CLOSED=0xFF0000
EMBED_COLOR_CLAIMED=0xFFFF00 EMBED_COLOR_CLAIMED=0xFFFF00
EMBED_COLOR_ESCALATED=0xFF6600 EMBED_COLOR_ESCALATED=0xFF6600
EMBED_COLOR_INFO=0x1e2124 EMBED_COLOR_INFO=0x1e2124

View File

@@ -1,15 +0,0 @@
/**
* bOSScord API: reference to the Discord bot client.
* Set in broccolini-discord.js when client fires "ready"; read by bosscord routes.
*/
let botClient = null;
function setBot(client) {
botClient = client;
}
function getBot() {
return botClient;
}
module.exports = { setBot, getBot };

View File

@@ -17,15 +17,8 @@ const { handleDiscordReply } = require('./handlers/messages');
const { sendTicketClosedEmail } = require('./services/gmail'); const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets'); const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
const { registerCommands } = require('./commands/register'); const { registerCommands } = require('./commands/register');
// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup.
const { setBot } = require('./api/botClient');
const { poll } = require('./gmail-poll'); const { poll } = require('./gmail-poll');
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog'); const { setClient: setDebugClient, logError } = require('./services/debugLog');
// Re-export utilities for any external consumers
const { sendGmailReply } = require('./services/gmail');
const { getNextTicketNumber } = require('./services/tickets');
const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils');
let gmailPollInterval = null; let gmailPollInterval = null;
// Track all background setInterval handles so shutdown can clear them. // Track all background setInterval handles so shutdown can clear them.
@@ -179,7 +172,6 @@ client.once('ready', async () => {
} }
await connectMongoDB(process.env.MONGODB_URI); await connectMongoDB(process.env.MONGODB_URI);
setDebugClient(client); setDebugClient(client);
setBot(client);
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined; const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => { httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
@@ -229,18 +221,6 @@ client.once('ready', async () => {
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)'); console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
console.log('✓ Discord bot ready. Tag:', client.user.tag); console.log('✓ Discord bot ready. Tag:', client.user.tag);
logSystem('Bot online', [
{ name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' },
{ name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` },
{ name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' },
{ name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' },
{ name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
{ name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
{ name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' },
{ name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' },
{ name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' }
]).catch(() => {});
}); });
client.login(CONFIG.DISCORD_TOKEN); client.login(CONFIG.DISCORD_TOKEN);
@@ -284,10 +264,7 @@ let shuttingDown = false;
async function handleShutdown(signal) { async function handleShutdown(signal) {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
await Promise.race([ console.log(`Bot shutting down (${signal})`);
logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]),
new Promise(r => setTimeout(r, 2000))
]);
for (const handle of activeIntervals) { for (const handle of activeIntervals) {
try { clearInterval(handle); } catch (_) {} try { clearInterval(handle); } catch (_) {}
} }
@@ -313,13 +290,5 @@ module.exports = {
client, client,
setGmailPollInterval, setGmailPollInterval,
clearGmailPollInterval, clearGmailPollInterval,
trackTimeout, trackTimeout
sendGmailReply,
sendTicketClosedEmail,
getNextTicketNumber,
getCleanBody,
detectGame,
stripEmailQuotes,
stripMobileFooter,
htmlToTextWithBlocks
}; };

View File

@@ -1,25 +1,9 @@
/** /**
* Broccolini Bot configuration and game lists. * Broccolini Bot configuration and game lists.
* Load dotenv so env is available when this module is required first.
* dotenv-expand resolves ${NGROK_URL} etc. in .env.
* *
* Never commit .env; agents must not modify .env without explicit user confirmation. * Never commit .env; agents must not modify .env without explicit user confirmation.
*/ */
const path = require('path'); require('dotenv').config({ debug: process.env.NODE_ENV === 'development' });
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const parsed = dotenv.config({ debug: process.env.NODE_ENV === 'development' });
dotenvExpand.expand(parsed);
// Also load repo-root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env)
const rootEnv = path.resolve(process.cwd(), '..', '.env');
const rootParsed = dotenv.config({ path: rootEnv });
if (!rootParsed.error && rootParsed.parsed) {
for (const [k, v] of Object.entries(rootParsed.parsed)) {
if (v != null && String(v).trim() !== '') process.env[k] = v;
}
dotenvExpand.expand(rootParsed);
}
function toInt(v, fallback) { function toInt(v, fallback) {
const n = parseInt(v, 10); const n = parseInt(v, 10);
@@ -31,23 +15,12 @@ const CONFIG = {
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null, DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID, TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets', TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
TICKET_T2_CATEGORY_NAME: process.env.TICKET_T2_CATEGORY_NAME || 'Tier 2 Escalated Tickets',
TICKET_T3_CATEGORY_NAME: process.env.TICKET_T3_CATEGORY_NAME || 'Tier 3 Escalated Tickets',
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean),
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID, DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
DISCORD_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean),
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING, ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID, ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID, TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
LOG_CHAN: process.env.LOGGING_CHANNEL_ID, LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
CLIENT_ID: process.env.DISCORD_APPLICATION_ID, CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
REFRESH_TOKEN: process.env.REFRESH_TOKEN, REFRESH_TOKEN: process.env.REFRESH_TOKEN,
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(), MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
@@ -75,9 +48,7 @@ const CONFIG = {
DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.', DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.',
AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true', AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true',
AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72), AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72),
AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.',
GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5), GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5),
TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3),
RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0), RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0),
RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60), RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60),
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean), BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
@@ -103,7 +74,6 @@ const CONFIG = {
BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌', BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌',
BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓', BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓',
EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00), EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00),
EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000),
EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00), EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600), EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124), EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
@@ -142,42 +112,8 @@ const GAME_ALIASES = {
CS2: 'Counter-Strike 2' CS2: 'Counter-Strike 2'
}; };
const GAME_NAME_TO_KEY = {
'Project Zomboid': 'project_zomboid',
'Satisfactory': 'satisfactory',
'Palworld': 'palworld',
'Minecraft': 'minecraft',
'Valheim': 'valheim',
'Enshrouded': 'enshrouded',
'7 Days to Die': '7_days_to_die',
'Hytale': 'hytale',
'ICARUS': 'icarus',
'Abiotic Factor': 'abiotic_factor',
'ARK: Survival Evolved': 'ark_survival_evolved',
'Conan Exiles': 'conan_exiles',
'Core Keeper': 'core_keeper',
'Counter-Strike 2': 'counter_strike_2',
'DayZ': 'dayz',
'ECO': 'eco',
'Factorio': 'factorio',
'FiveM': 'fivem',
'The Front': 'the_front',
"Garry's Mod": 'garrys_mod',
'Necesse': 'necesse',
'Rust': 'rust',
'Sons of the Forest': 'sons_of_the_forest',
'Soulmask': 'soulmask',
'Star Rupture': 'star_rupture',
'Terraria': 'terraria',
'VEIN': 'vein',
'Vintage Story': 'vintage_story',
'Voyagers of Nera': 'voyagers_of_nera',
'V Rising': 'v_rising'
};
module.exports = { module.exports = {
CONFIG, CONFIG,
GAME_NAMES, GAME_NAMES,
GAME_ALIASES, GAME_ALIASES
GAME_NAME_TO_KEY
}; };

View File

@@ -22,23 +22,16 @@ async function connectMongoDB(uri, options = {}) {
await mongoose.connect(uri, defaultOptions); await mongoose.connect(uri, defaultOptions);
console.log('✓ Connected to MongoDB'); console.log('✓ Connected to MongoDB');
// Handle connection events
mongoose.connection.on('error', (err) => { mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err); console.error('MongoDB connection error:', err);
const { logSystem: ls } = require('./services/debugLog');
ls('MongoDB error', [{ name: 'Error', value: err.message }], null, 0xFF0000).catch(() => {});
}); });
mongoose.connection.on('disconnected', () => { mongoose.connection.on('disconnected', () => {
console.warn('MongoDB disconnected. Attempting to reconnect...'); console.warn('MongoDB disconnected. Attempting to reconnect...');
const { logSystem: ls } = require('./services/debugLog');
ls('MongoDB disconnected', [], null, 0xFFFF00).catch(() => {});
}); });
mongoose.connection.on('reconnected', () => { mongoose.connection.on('reconnected', () => {
console.log('✓ MongoDB reconnected'); console.log('✓ MongoDB reconnected');
const { logSystem: ls } = require('./services/debugLog');
ls('MongoDB reconnected', []).catch(() => {});
}); });
} catch (err) { } catch (err) {

View File

@@ -7,7 +7,7 @@ const {
EmbedBuilder EmbedBuilder
} = require('discord.js'); } = require('discord.js');
const { mongoose, withRetry } = require('./db-connection'); const { mongoose, withRetry } = require('./db-connection');
const { CONFIG, GAME_NAME_TO_KEY } = require('./config'); const { CONFIG } = require('./config');
const { const {
getCleanBody, getCleanBody,
extractRawEmail, extractRawEmail,
@@ -19,7 +19,7 @@ const {
} = require('./utils'); } = require('./utils');
const { getGmailClient } = require('./services/gmail'); const { getGmailClient } = require('./services/gmail');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError, logGmail, logAutomation } = require('./services/debugLog'); const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue'); const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents'); const { getTicketActionRow } = require('./utils/ticketComponents');
@@ -29,7 +29,6 @@ const Transcript = mongoose.model('Transcript');
let isPolling = false; let isPolling = false;
let authErrorNotified = false; let authErrorNotified = false;
let pollSuspended = false; let pollSuspended = false;
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
function setPollSuspended(val) { function setPollSuspended(val) {
pollSuspended = !!val; pollSuspended = !!val;
@@ -45,13 +44,6 @@ async function poll(client) {
if (isPolling || pollSuspended) return; if (isPolling || pollSuspended) return;
isPolling = true; isPolling = true;
try { try {
pollCount++;
if (pollCount % 10 === 0) {
if (totalProcessed > 0 || totalSkipped > 0 || totalErrors > 0) {
logAutomation('Gmail poll summary', null, `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`).catch(() => {});
}
pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0;
}
console.log('Running poll()...'); console.log('Running poll()...');
try { try {
const gmail = getGmailClient(); const gmail = getGmailClient();
@@ -89,7 +81,6 @@ async function poll(client) {
email.data.payload.headers.find(h => h.name === 'From') email.data.payload.headers.find(h => h.name === 'From')
?.value || ''; ?.value || '';
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) { if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
totalSkipped++;
await gmail.users.messages.batchModify({ await gmail.users.messages.batchModify({
userId: 'me', userId: 'me',
requestBody: { requestBody: {
@@ -173,7 +164,6 @@ async function poll(client) {
// Check ticket limits before creating // Check ticket limits before creating
const limitCheck = await checkTicketLimits(sEmail); const limitCheck = await checkTicketLimits(sEmail);
if (!limitCheck.ok) { if (!limitCheck.ok) {
totalSkipped++;
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`); console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
await gmail.users.messages.batchModify({ await gmail.users.messages.batchModify({
userId: 'me', userId: 'me',
@@ -224,11 +214,6 @@ async function poll(client) {
const detectedGame = detectGame(subject, rawBody); const detectedGame = detectGame(subject, rawBody);
const gameKey =
detectedGame && detectedGame !== 'Not Mentioned'
? GAME_NAME_TO_KEY[detectedGame] || null
: null;
const buttons = getTicketActionRow({ escalationTier: 0 }); const buttons = getTicketActionRow({ escalationTier: 0 });
const ticketInfoEmbed = new EmbedBuilder() const ticketInfoEmbed = new EmbedBuilder()
@@ -326,8 +311,6 @@ async function poll(client) {
}, },
{ upsert: true, new: true } { upsert: true, new: true }
)); ));
totalProcessed++;
logGmail(subject, sEmail, number, detectedGame).catch(() => {});
} }
console.log('Archiving/reading Gmail message', msgRef.id); console.log('Archiving/reading Gmail message', msgRef.id);
await gmail.users.messages.batchModify({ await gmail.users.messages.batchModify({
@@ -359,7 +342,6 @@ async function poll(client) {
} }
} }
totalErrors++;
console.error('POLL ERROR:', e); console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {}); logError('Gmail poll', e, null, client).catch(() => {});
} }

View File

@@ -16,14 +16,14 @@ const {
} = require('discord.js'); } = require('discord.js');
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, getSenderLocal, toDiscordSafeName } = require('../services/tickets'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail'); const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents'); const { getTicketActionRow } = require('../utils/ticketComponents');
const { sanitizeEmbedText, truncateEmbedDescription, truncateEmbedField, enforceEmbedLimit } = require('../utils'); const { sanitizeEmbedText, truncateEmbedDescription, enforceEmbedLimit } = require('../utils');
const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands'); const { runEscalation, runDeescalation } = require('./commands');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
const { logError, logSystem } = require('../services/debugLog'); const { logError } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript'); const Transcript = mongoose.model('Transcript');
@@ -328,8 +328,6 @@ async function handleClaim(interaction, ticket) {
} }
if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) { if (isClaimed && !isClaimedByMe && !CONFIG.ALLOW_CLAIM_OVERWRITE) {
const { logSecurity } = require('../services/debugLog');
logSecurity('Unauthorized button attempt', interaction.user, interaction.customId).catch(() => {});
return interaction.reply({ return interaction.reply({
content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`, content: `This ticket is already claimed by **${freshTicket.claimedBy}**.`,
ephemeral: true ephemeral: true
@@ -510,13 +508,8 @@ async function handleConfirmClose(interaction, ticket, sendEmail = true) {
files: [dmFile] files: [dmFile]
}); });
} catch (dmErr) { } catch (dmErr) {
// 50007 = "Cannot send messages to this user" — user has DMs off. Expected class; debug-level only. // 50007 = "Cannot send messages to this user" — user has DMs off. Expected; ignore.
if (dmErr?.code === 50007) { if (dmErr?.code !== 50007) {
logSystem('Transcript DM skipped (recipient has DMs disabled)', [
{ name: 'User', value: creatorId },
{ name: 'Channel', value: channelName }
]).catch(() => {});
} else {
logError('transcript-dm', dmErr).catch(() => {}); logError('transcript-dm', dmErr).catch(() => {});
} }
} }
@@ -662,8 +655,6 @@ async function handleTicketModal(interaction) {
parentCategoryId: parentCategoryIdForTicket parentCategoryId: parentCategoryIdForTicket
}); });
const displayName = interaction.member?.displayName || interaction.user.username;
const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description; const descTrimmed = description.length > 500 ? description.slice(0, 497) + '…' : description;
const welcomeEmbed = new EmbedBuilder() const welcomeEmbed = new EmbedBuilder()

View File

@@ -3,7 +3,6 @@
*/ */
const { const {
ChannelType, ChannelType,
ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
ButtonStyle, ButtonStyle,
AttachmentBuilder, AttachmentBuilder,
@@ -18,7 +17,7 @@ const { sendTicketNotificationEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents'); const { getTicketActionRow } = require('../utils/ticketComponents');
const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue'); const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channelQueue');
const { setNotifyDm } = require('../services/staffSettings'); const { setNotifyDm } = require('../services/staffSettings');
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'); const { logError } = require('../services/debugLog');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -51,7 +50,6 @@ async function requireStaffRole(interaction) {
content: `This command is only available to the support team (${roleMention}).`, content: `This command is only available to the support team (${roleMention}).`,
ephemeral: true ephemeral: true
}); });
logSecurity('Unauthorized command attempt', interaction.user, interaction.commandName).catch(() => {});
return true; return true;
} }
@@ -130,7 +128,6 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
ticket, ticket,
null, null,
emailBody, emailBody,
escalatorName,
interaction.user.id interaction.user.id
); );
} catch (emailErr) { } catch (emailErr) {

View File

@@ -3,7 +3,7 @@
*/ */
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { extractRawEmail } = require('../utils'); const { extractRawEmail, isStaff } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail'); 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');
@@ -19,12 +19,11 @@ async function handleDiscordReply(m) {
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean(); const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (!ticket) return; 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 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); const isStaffMember = isStaff(memberForCheck);
Ticket.updateOne( Ticket.updateOne(
{ discordThreadId: m.channel.id }, { discordThreadId: m.channel.id },
{ $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } } { $set: { lastActivity: new Date() } }
).catch(() => {}); ).catch(() => {});
// DM the claimer if they have notifydm on and a non-staff user replied. // DM the claimer if they have notifydm on and a non-staff user replied.

View File

@@ -5,8 +5,6 @@ var mongoose = require('mongoose');
const ticketSchema = new mongoose.Schema({ const ticketSchema = new mongoose.Schema({
gmailThreadId: { type: String, required: true, unique: true, index: true }, gmailThreadId: { type: String, required: true, unique: true, index: true },
discordThreadId: String, discordThreadId: String,
broccoliniTicketId: Number,
lastSyncedBroccoliniArticleId: Number,
senderEmail: { type: String, required: true }, senderEmail: { type: String, required: true },
subject: String, subject: String,
createdAt: { type: Date, default: Date.now }, createdAt: { type: Date, default: Date.now },
@@ -16,18 +14,12 @@ const ticketSchema = new mongoose.Schema({
escalated: { type: Boolean, default: false }, escalated: { type: Boolean, default: false },
escalationTier: { type: Number, default: 0 }, escalationTier: { type: Number, default: 0 },
ticketNumber: Number, ticketNumber: Number,
renameCount: { type: Number, default: 0 },
renameWindowStart: Date,
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] }, priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
ticketTag: String, ticketTag: String,
lastActivity: Date, lastActivity: Date,
reminderSent: { type: Boolean, default: false },
welcomeMessageId: String, welcomeMessageId: String,
claimerId: String, claimerId: String,
staffChannelId: String,
parentCategoryId: String, parentCategoryId: String,
unclaimedRemindersSent: { type: [Number], default: [] },
lastMessageAuthorIsStaff: { type: Boolean, default: false },
pendingDelete: { type: Boolean, default: false } pendingDelete: { type: Boolean, default: false }
}); });
ticketSchema.index({ status: 1, lastActivity: 1 }); ticketSchema.index({ status: 1, lastActivity: 1 });

83
package-lock.json generated
View File

@@ -11,13 +11,13 @@
"dependencies": { "dependencies": {
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"dotenv-expand": "^11.0.6",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.3.2", "express-rate-limit": "^8.3.2",
"googleapis": "^171.4.0", "googleapis": "^171.4.0",
"mongodb": "^7.1.0", "mongoose": "^6.12.0"
"mongoose": "^6.12.0", },
"p-queue": "^6.6.2" "devDependencies": {
"mongodb": "^7.1.0"
} }
}, },
"node_modules/@aws-crypto/sha256-browser": { "node_modules/@aws-crypto/sha256-browser": {
@@ -1277,6 +1277,7 @@
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz",
"integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==", "integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"sparse-bitfield": "^3.0.3" "sparse-bitfield": "^3.0.3"
@@ -1964,6 +1965,7 @@
"version": "13.0.0", "version": "13.0.0",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/webidl-conversions": "*" "@types/webidl-conversions": "*"
@@ -2344,33 +2346,6 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/dotenv-expand": {
"version": "11.0.7",
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz",
"integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==",
"license": "BSD-2-Clause",
"dependencies": {
"dotenv": "^16.4.5"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dotenv-expand/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2466,11 +2441,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/express": { "node_modules/express": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -3068,6 +3038,7 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge-descriptors": { "node_modules/merge-descriptors": {
@@ -3135,6 +3106,7 @@
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz",
"integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@mongodb-js/saslprep": "^1.3.0", "@mongodb-js/saslprep": "^1.3.0",
@@ -3181,6 +3153,7 @@
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@types/whatwg-url": "^13.0.0", "@types/whatwg-url": "^13.0.0",
@@ -3194,6 +3167,7 @@
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz",
"integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
@@ -3391,40 +3365,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"engines": {
"node": ">=4"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/package-json-from-dist": { "node_modules/package-json-from-dist": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -3783,6 +3723,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"memory-pager": "^1.0.2" "memory-pager": "^1.0.2"
@@ -3919,6 +3860,7 @@
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"punycode": "^2.3.1" "punycode": "^2.3.1"
@@ -4014,6 +3956,7 @@
"version": "14.2.0", "version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tr46": "^5.1.0", "tr46": "^5.1.0",

View File

@@ -2,13 +2,13 @@
"dependencies": { "dependencies": {
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dotenv": "^17.2.4", "dotenv": "^17.2.4",
"dotenv-expand": "^11.0.6",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.3.2", "express-rate-limit": "^8.3.2",
"googleapis": "^171.4.0", "googleapis": "^171.4.0",
"mongodb": "^7.1.0", "mongoose": "^6.12.0"
"mongoose": "^6.12.0", },
"p-queue": "^6.6.2" "devDependencies": {
"mongodb": "^7.1.0"
}, },
"name": "broccolini-bot", "name": "broccolini-bot",
"version": "1.0.0", "version": "1.0.0",

View File

@@ -4,7 +4,6 @@ const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { safeEqual } = require('../utils'); const { safeEqual } = require('../utils');
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
const { logSystem } = require('../services/debugLog');
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema'); const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
const router = express.Router(); const router = express.Router();
@@ -54,11 +53,6 @@ router.post('/config', express.json(), async (req, res) => {
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` }); return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
} }
const result = applyConfigUpdates(updates); const result = applyConfigUpdates(updates);
const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', ');
await logSystem('Config updated via settings UI', [
{ name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false },
{ name: 'Errors', value: errorSummary || 'none', inline: false }
]).catch(() => {});
// Partial success stays 200 so the client can still apply the successful keys. // Partial success stays 200 so the client can still apply the successful keys.
// Only 400 when every submitted key failed validation (i.e. the save did nothing). // Only 400 when every submitted key failed validation (i.e. the save did nothing).
const totalSubmitted = Object.keys(updates).length; const totalSubmitted = Object.keys(updates).length;
@@ -69,7 +63,7 @@ router.post('/config', express.json(), async (req, res) => {
// GET /discord/guild — return guild info for smart dropdowns // GET /discord/guild — return guild info for smart dropdowns
router.get('/discord/guild', async (req, res) => { router.get('/discord/guild', async (req, res) => {
try { try {
const client = require('../api/botClient').getBot(); const client = require('../broccolini-discord').client;
if (!client) return res.status(503).json({ error: 'Bot not ready' }); if (!client) return res.status(503).json({ error: 'Bot not ready' });
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
@@ -178,9 +172,6 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
if (parent.setGmailPollInterval) { if (parent.setGmailPollInterval) {
parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS); parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
} }
await logSystem('Gmail OAuth reloaded', [
{ name: 'Account', value: emailAddress, inline: false }
]).catch(() => {});
res.json({ ok: true, email: emailAddress }); res.json({ ok: true, email: emailAddress });
} catch (err) { } catch (err) {
const oauthError = err && err.response && err.response.data && err.response.data.error; const oauthError = err && err.response && err.response.data && err.response.data.error;

View File

@@ -19,8 +19,7 @@
const ALLOWED_CONFIG_KEYS = new Set([ const ALLOWED_CONFIG_KEYS = new Set([
// Ticket settings // Ticket settings
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME', 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID',
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
// Escalation categories // Escalation categories
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
@@ -29,12 +28,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'ADMIN_ID', 'ADMIN_ID',
// Channel IDs // Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'DISCORD_CHANNEL_ID',
'RENAME_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
// Messages and labels // Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', 'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding // Branding
@@ -46,11 +44,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', 'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE', 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
// Limits and thresholds // Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY', 'GLOBAL_TICKET_LIMIT',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
// Embed colors // Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI' 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
]); ]);
@@ -201,32 +199,7 @@ function getValidator(key) {
return VALIDATORS[inferType(key)]; return VALIDATORS[inferType(key)];
} }
// Pre-build per-key validator map for callers that want O(1) lookup
// (and for the smoke test / boot log).
const ALL_VALIDATORS = {};
for (const key of ALLOWED_CONFIG_KEYS) {
ALL_VALIDATORS[key] = getValidator(key);
}
// ---------- Startup log (no-op if console.log is suppressed) ----------
(function logDistribution() {
const dist = {};
const fallback = [];
for (const [key, v] of Object.entries(ALL_VALIDATORS)) {
dist[v.type] = (dist[v.type] || 0) + 1;
if (v.type === 'string') fallback.push(key);
}
console.log('[configSchema] type distribution:', JSON.stringify(dist));
if (fallback.length) {
console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', '));
}
})();
module.exports = { module.exports = {
ALLOWED_CONFIG_KEYS, ALLOWED_CONFIG_KEYS,
VALIDATORS, getValidator
ALL_VALIDATORS,
getValidator,
inferType
}; };

View File

@@ -58,13 +58,6 @@ async function logWarn(context, message, overrideClient = null) {
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient); await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
} }
// --- logEvent (generic posts to any configured channel) ---
async function logEvent(channelConfigKey, embed, overrideClient = null) {
const channelId = CONFIG[channelConfigKey];
await sendToChannel(channelId, embed, overrideClient);
}
// --- logTicketEvent --- // --- logTicketEvent ---
async function logTicketEvent(action, fields, interaction = null) { async function logTicketEvent(action, fields, interaction = null) {
@@ -79,46 +72,9 @@ async function logTicketEvent(action, fields, interaction = null) {
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client); await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
} }
// --- logGmail ---
async function logGmail(...args) { return; }
// --- logAutomation ---
async function logAutomation(...args) { return; }
// --- logSecurity ---
async function logSecurity(...args) { return; }
// --- logIntegrity ---
async function logIntegrity(issue, detail, overrideClient = null) {
const embed = new EmbedBuilder()
.setTitle('Ticket Integrity Issue')
.setColor(0xFF0000)
.addFields(
{ name: 'Issue', value: String(issue).slice(0, 256), inline: false },
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
)
.setTimestamp();
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
}
// --- logSystem ---
async function logSystem(...args) { return; }
module.exports = { module.exports = {
setClient, setClient,
logError, logError,
logWarn, logWarn,
logEvent, logTicketEvent
logTicketEvent,
logGmail,
logAutomation,
logSecurity,
logIntegrity,
logSystem
}; };

View File

@@ -1,5 +1,5 @@
/** /**
* Gmail service OAuth client, send reply, send ticket-closed email. * Gmail service OAuth client, send reply, send ticket-closed/notification emails.
*/ */
const { google } = require('googleapis'); const { google } = require('googleapis');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
@@ -56,8 +56,6 @@ function getGmailClient() {
* *
* Throws if the env file is missing the token, or if the probe call (getProfile) * 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. * fails — the caller surfaces the error so the UI can see why.
*
* @returns {Promise<{emailAddress: string}>}
*/ */
async function reloadGmailClient() { async function reloadGmailClient() {
const envMap = readEnvFile(); const envMap = readEnvFile();
@@ -74,276 +72,53 @@ async function reloadGmailClient() {
return { emailAddress: profile.data.emailAddress }; return { emailAddress: profile.data.emailAddress };
} }
async function sendTicketClosedEmail(ticket, closerName, userId = null) { // Fetch the first message's Subject + Message-ID from a Gmail thread, used to
// derive a faithful Re: subject and a proper In-Reply-To/References header.
async function fetchThreadSubjectAndMsgId(gmail, threadId) {
try { try {
const gmail = getGmailClient(); const thread = await gmail.users.threads.get({ userId: 'me', id: threadId });
const firstMsg = (thread.data.messages || [])[0];
// Send to the ticket sender (customer), not derived from thread (which can be support) const headers = firstMsg?.payload?.headers || [];
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); return {
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; subject: headers.find(h => h.name === 'Subject')?.value || null,
if (!EMAIL_RE.test(recipientEmail)) { msgId: sanitizeHeaderValue(headers.find(h => h.name === 'Message-ID')?.value) || null
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); };
return; } catch (_) {
} return { subject: null, msgId: null };
let originalSubject = null;
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const firstMsg = messages[0];
if (firstMsg?.payload?.headers) {
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) originalSubject = subj;
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
const baseSubject = originalSubject || ticket.subject || 'Support';
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
const messageBody = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
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(messageBody).replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()}
</div>`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(messageBody);
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
msgId && `In-Reply-To: ${msgId}`,
msgId && `References: ${msgId}`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`
].filter(Boolean);
const raw = Buffer.from([
...headers,
'',
`--${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: 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 // Strip leading "Re:" variants and re-prepend a single one, then RFC 2047 encode.
const { mongoose } = require('../db-connection'); function encodeReplySubject(baseSubject) {
const StaffSignature = mongoose.model('StaffSignature'); const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safe = sanitizeHeaderValue(`Re: ${stripped}`);
/** return `=?utf-8?B?${Buffer.from(safe).toString('base64')}?=`;
* 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 originalSubject = null;
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const firstMsg = messages[0];
if (firstMsg?.payload?.headers) {
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) originalSubject = subj;
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
// Thread-safety: derive Subject from the last thread message; strip any leading
// Re:/RE:/Re : variants and re-prepend a single "Re: ". Fall back to subjectLine
// (legacy param) only if the thread lookup gave us nothing.
const baseSubject = originalSubject || subjectLine || ticket.subject || 'Support';
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
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(messageBody || '').replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()}
</div>`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(messageBody || '');
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
msgId && `In-Reply-To: ${msgId}`,
msgId && `References: ${msgId}`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`
].filter(Boolean);
const raw = Buffer.from([
...headers,
'',
`--${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: ticket.gmailThreadId }
});
} catch (err) {
console.error('Ticket notification email error:', err);
}
} }
/** // Compose and send a multipart/alternative reply on an existing Gmail thread.
* Send a Gmail reply to a ticket async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) {
* @param {string} threadId - Gmail thread ID const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
* @param {string} replyText - Reply text const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
* @param {string} recipientEmail - Recipient email const safeStaffSigText = sigBlocks.text;
* @param {string} subject - Subject line
* @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,
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')}?=`;
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 = ` const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;"> <div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p> <p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''} ${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()} ${buildCompanySigHtml()}
</div>`; </div>`;
const plainBody = [messageText || ''];
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
plainBody.push('', ...buildCompanySigText().split('\n'));
const boundary = '000000000000' + Date.now().toString(16); const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(replyText);
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [ const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${safeRecipient}`, `To: ${recipient}`,
`Subject: ${utf8Subject}`, `Subject: ${encodedSubject}`,
safeMessageId && `In-Reply-To: ${safeMessageId}`, msgId && `In-Reply-To: ${msgId}`,
safeMessageId && `References: ${safeMessageId}`, msgId && `References: ${msgId}`,
'MIME-Version: 1.0', 'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"` `Content-Type: multipart/alternative; boundary="${boundary}"`
].filter(Boolean); ].filter(Boolean);
@@ -366,9 +141,92 @@ async function sendGmailReply(
.toString('base64') .toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
await gmail.users.messages.send({ await gmail.users.messages.send({ userId: 'me', requestBody: { raw, threadId } });
userId: 'me', }
requestBody: { raw, threadId }
// Resolve and validate a customer recipient from a ticket's senderEmail.
// Returns null and logs if invalid or self-addressed.
function resolveCustomerRecipient(ticket, context) {
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return null;
if (!EMAIL_RE.test(recipientEmail)) {
logError(`${context}: invalid recipient`, new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
return null;
}
return recipientEmail;
}
async function sendTicketClosedEmail(ticket, closerName, userId = null) {
try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketClosedEmail');
if (!recipient) return;
const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId,
recipient,
encodedSubject,
msgId,
messageText,
userId
});
} catch (err) {
console.error('Ticket closed email error:', err);
}
}
/**
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
* @param {string} subjectLine - Fallback subject if the thread can't be queried
* @param {string} messageBody - Plain or HTML message body
* @param {string} [userId] - Discord user ID for signature (optional)
*/
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) {
try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
if (!recipient) return;
const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support');
await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId,
recipient,
encodedSubject,
msgId,
messageText: messageBody,
userId
});
} catch (err) {
console.error('Ticket notification email error:', err);
}
}
/**
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId
* (typically pulled from the latest non-self message in the thread).
*/
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) {
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
if (!EMAIL_RE.test(safeRecipient)) {
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
return null;
}
const gmail = getGmailClient();
await sendThreadedEmail(gmail, {
threadId,
recipient: safeRecipient,
encodedSubject: encodeReplySubject(subject || 'Support'),
msgId: sanitizeHeaderValue(messageId) || null,
messageText: replyText,
userId
}); });
} }

View File

@@ -2,11 +2,9 @@
* Ticket database helpers counters, rename, limits, auto-close, * Ticket database helpers counters, rename, limits, auto-close,
* reminders, auto-unclaim, channel creation. * reminders, auto-unclaim, channel creation.
*/ */
const { ChannelType, PermissionFlagsBits } = require('discord.js'); const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection'); const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog');
const { enqueueSend, enqueueDelete } = require('./channelQueue'); const { enqueueSend, enqueueDelete } = require('./channelQueue');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -30,9 +28,6 @@ async function getNextTicketNumber(senderEmail) {
// primary bot's 2/10min per-channel budget here; 429s from the secondary // primary bot's 2/10min per-channel budget here; 429s from the secondary
// bot surface via utils/renamer.js instead. // bot surface via utils/renamer.js instead.
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
const RENAME_LIMIT = 2;
function getSenderLocal(senderEmail) { function getSenderLocal(senderEmail) {
return (senderEmail || 'unknown').split('@')[0].toLowerCase(); return (senderEmail || 'unknown').split('@')[0].toLowerCase();
} }
@@ -90,16 +85,6 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
} }
} }
// Retained for external callers (bOSScord, scripts). The gate now lives in
// the secondary bot's rate bucket; this helper no longer touches Mongo.
async function canRename(_ticket) {
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
}
function minutesFromMs(ms) {
return Math.max(1, Math.ceil(ms / 60000));
}
// --- RATE LIMIT (per-user ticket creation) --- // --- RATE LIMIT (per-user ticket creation) ---
const ticketCreationByUser = new Map(); // userId -> { count, resetAt } const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
@@ -156,15 +141,6 @@ function escapeCategoryNameForRegex(name) {
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
/**
* @deprecated Use getOrCreateTicketCategory instead.
* @returns {null}
*/
function pickTicketCategoryId(guild, categoryIds) {
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
return null;
}
function countChannelsInCategory(guild, categoryId) { function countChannelsInCategory(guild, categoryId) {
return guild.channels.cache.filter(c => c.parentId === categoryId).size; return guild.channels.cache.filter(c => c.parentId === categoryId).size;
} }
@@ -272,52 +248,6 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
} }
} }
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
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;
}
// --- LIMITS & PERMISSIONS --- // --- LIMITS & PERMISSIONS ---
async function checkTicketLimits(senderEmail) { async function checkTicketLimits(senderEmail) {
@@ -334,22 +264,12 @@ async function checkTicketLimits(senderEmail) {
return { ok: true }; return { ok: true };
} }
function hasBlacklistedRole(member) {
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
return false;
}
return member.roles.cache.some(role =>
CONFIG.BLACKLISTED_ROLES.includes(role.id)
);
}
// --- ACTIVITY --- // --- ACTIVITY ---
async function updateTicketActivity(gmailThreadId) { async function updateTicketActivity(gmailThreadId) {
const now = new Date();
await Ticket.updateOne( await Ticket.updateOne(
{ gmailThreadId }, { gmailThreadId },
{ $set: { lastActivity: now, reminderSent: false } } { $set: { lastActivity: new Date() } }
); );
} }
@@ -366,9 +286,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
lastActivity: { $lt: cutoffTime, $ne: null } lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean()); }).sort({ createdAt: 1 }).limit(500).lean());
let checked = 0, closed = 0;
for (const ticket of staleTickets) { for (const ticket of staleTickets) {
checked++;
try { try {
const guild = client.guilds.cache.first(); const guild = client.guilds.cache.first();
if (!guild) continue; if (!guild) continue;
@@ -395,53 +313,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
)).catch(() => {}); )).catch(() => {});
}).catch(() => {}); }).catch(() => {});
}, 5000); }, 5000);
closed++;
} }
} catch (error) { } catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error); console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
} }
} }
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
}
async function checkReminders(client) {
if (!CONFIG.REMINDER_ENABLED) return;
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: reminderTime, $ne: null },
reminderSent: false
}).lean());
let checked = 0, reminded = 0;
for (const ticket of ticketsNeedingReminder) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
const ping = ticket.claimedBy
? `<@${ticket.claimedBy}>`
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
const message = CONFIG.REMINDER_MESSAGE
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
.replace(/\{ping\}/g, ping);
await enqueueSend(channel, message);
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { reminderSent: true } }
));
reminded++;
}
} catch (error) {
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
}
}
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
} }
async function checkAutoUnclaim(client) { async function checkAutoUnclaim(client) {
@@ -454,9 +330,7 @@ async function checkAutoUnclaim(client) {
lastActivity: { $lt: unclaimTime, $ne: null } lastActivity: { $lt: unclaimTime, $ne: null }
}).lean()); }).lean());
let checked = 0, unclaimed = 0;
for (const ticket of staleClaimedTickets) { for (const ticket of staleClaimedTickets) {
checked++;
try { try {
const guild = client.guilds.cache.first(); const guild = client.guilds.cache.first();
if (!guild) continue; if (!guild) continue;
@@ -473,18 +347,16 @@ async function checkAutoUnclaim(client) {
); );
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`); console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
unclaimed++;
} }
} catch (error) { } catch (error) {
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error); console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
} }
} }
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
} }
async function reconcileDeletedTicketChannels(client) { async function reconcileDeletedTicketChannels(client) {
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return { checked: 0, reconciled: 0 }; if (!guild) return;
// Bounded per-tick; a larger backlog drains in subsequent hourly runs. // Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({ const openTickets = await Ticket.find({
@@ -492,9 +364,7 @@ async function reconcileDeletedTicketChannels(client) {
discordThreadId: { $ne: null } discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean(); }).sort({ createdAt: 1 }).limit(500).lean();
let checked = 0, reconciled = 0;
for (const ticket of openTickets) { for (const ticket of openTickets) {
checked++;
try { try {
let channel = guild.channels.cache.get(ticket.discordThreadId); let channel = guild.channels.cache.get(ticket.discordThreadId);
if (!channel) { if (!channel) {
@@ -505,17 +375,11 @@ async function reconcileDeletedTicketChannels(client) {
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', discordThreadId: null } } { $set: { status: 'closed', discordThreadId: null } }
); );
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
reconciled++;
} }
} catch (err) { } catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err); console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
} }
} }
if (reconciled > 0) {
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
}
return { checked, reconciled };
} }
/** /**
@@ -525,8 +389,7 @@ async function reconcileDeletedTicketChannels(client) {
*/ */
async function resumePendingDeletes(client) { async function resumePendingDeletes(client) {
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []); const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
if (!pending.length) return 0; if (!pending.length) return;
let resumed = 0;
for (const ticket of pending) { for (const ticket of pending) {
try { try {
const guild = client.guilds.cache.first(); const guild = client.guilds.cache.first();
@@ -534,7 +397,6 @@ async function resumePendingDeletes(client) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
enqueueDelete(channel).catch(() => {}); enqueueDelete(channel).catch(() => {});
resumed++;
} }
} }
Ticket.updateOne( Ticket.updateOne(
@@ -545,33 +407,22 @@ async function resumePendingDeletes(client) {
console.error('resumePendingDeletes error:', e); console.error('resumePendingDeletes error:', e);
} }
} }
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
return resumed;
} }
module.exports = { module.exports = {
getNextTicketNumber, getNextTicketNumber,
getOrCreateTicketCategory, getOrCreateTicketCategory,
cleanupEmptyOverflowCategory, cleanupEmptyOverflowCategory,
RENAME_WINDOW_MS,
RENAME_LIMIT,
getSenderLocal, getSenderLocal,
toDiscordSafeName, toDiscordSafeName,
resolveCreatorNickname, resolveCreatorNickname,
makeTicketName, makeTicketName,
canRename,
minutesFromMs,
checkTicketCreationRateLimit, checkTicketCreationRateLimit,
createTicketChannel,
checkTicketLimits, checkTicketLimits,
hasBlacklistedRole,
updateTicketActivity, updateTicketActivity,
checkAutoClose, checkAutoClose,
checkReminders,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels, reconcileDeletedTicketChannels,
resumePendingDeletes, resumePendingDeletes,
startTicketsSweeps, startTicketsSweeps
sweepTicketCreationByUser,
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
}; };

View File

@@ -173,31 +173,6 @@ function extractRawEmail(headerValue) {
return match ? match[1].trim() : headerValue.trim(); return match ? match[1].trim() : headerValue.trim();
} }
// --- DATE ---
const getFormattedDate = () => {
const now = new Date();
const datePart = now
.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric'
})
.replace(/\//g, '-');
const timePart = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
});
const tzPart = new Intl.DateTimeFormat('en-US', {
timeZoneName: 'short'
})
.formatToParts(now)
.find(p => p.type === 'timeZoneName').value;
return `${datePart} ${timePart} ${tzPart}`;
};
// --- GAME DETECTION --- // --- GAME DETECTION ---
// Map<lowercase-alias, { canonical, re }> built once at module load so detectGame // Map<lowercase-alias, { canonical, re }> built once at module load so detectGame
// doesn't allocate a fresh RegExp per game/alias per call. // doesn't allocate a fresh RegExp per game/alias per call.
@@ -233,16 +208,6 @@ function getPriorityEmoji(priority) {
} }
} }
function getPriorityColor(priority) {
switch (priority) {
case 'high': return 0xFF0000;
case 'low': return 0x00FF00;
case 'normal':
case 'medium':
default: return CONFIG.EMBED_COLOR_INFO;
}
}
// --- TEMPLATE VARIABLES --- // --- TEMPLATE VARIABLES ---
function replaceVariables(template, context = {}) { function replaceVariables(template, context = {}) {
@@ -292,13 +257,6 @@ function sanitizeEmbedText(str) {
// --- EMBED TRUNCATION --- // --- EMBED TRUNCATION ---
/** Truncate a string for use as an embed field value (max 1024). */
function truncateEmbedField(str, max = 1024) {
if (str == null) return '';
const s = String(str);
return s.length > max ? s.slice(0, max - 3) + '...' : s;
}
/** Truncate a string for use as an embed description (max 4096). */ /** Truncate a string for use as an embed description (max 4096). */
function truncateEmbedDescription(str, max = 4096) { function truncateEmbedDescription(str, max = 4096) {
if (str == null) return ''; if (str == null) return '';
@@ -381,24 +339,17 @@ function enforceEmbedLimit(embeds) {
module.exports = { module.exports = {
sanitizeEmbedText, sanitizeEmbedText,
truncateEmbedField,
truncateEmbedDescription, truncateEmbedDescription,
enforceEmbedLimit, enforceEmbedLimit,
BLOCK_TAG_REGEX,
escapeRegex,
escapeHtml, escapeHtml,
safeEqual, safeEqual,
isStaff, isStaff,
decodeHtmlEntities,
htmlToTextWithBlocks, htmlToTextWithBlocks,
decodeGmailData,
getCleanBody, getCleanBody,
stripEmailQuotes, stripEmailQuotes,
stripMobileFooter, stripMobileFooter,
extractRawEmail, extractRawEmail,
getFormattedDate,
detectGame, detectGame,
getPriorityEmoji, getPriorityEmoji,
getPriorityColor,
replaceVariables replaceVariables
}; };