diff --git a/.env.example b/.env.example index 2cf9be0..f4b50af 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,6 @@ TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close LOGGING_CHANNEL_ID= # Channel for lifecycle log messages DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional BACKUP_EXPORT_CHANNEL_ID= # Channel where /backup and /export post .txt files; optional -ACCOUNT_INFO_CHANNEL_ID= # Channel for account info lookups; optional DISCORD_CHANNEL_ID= # General Discord channel (if used) # --- Discord: Ticket copy & buttons --- @@ -59,11 +58,6 @@ NGROK_URL= # Public URL (optional); run ngrok outside thi 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) -# --- bOSScord (support cockpit) --- -# Set BOSSCORD_API_KEY to enable /api (ticket list, thread, send message). Use same key in bOSScord app login. -# BOSSCORD_API_KEY= # e.g. from: openssl rand -hex 32 -# BOSSCORD_CORS_ORIGIN=* # Optional; default * (set to bOSScord origin in production) - # --- Database --- MONGODB_URI= # MongoDB connection string (e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db) # MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently) diff --git a/.env.test.example b/.env.test.example index e6ff759..8c087e0 100644 --- a/.env.test.example +++ b/.env.test.example @@ -36,7 +36,6 @@ TRANSCRIPT_CHANNEL_ID= LOGGING_CHANNEL_ID= DEBUGGING_CHANNEL_ID= BACKUP_EXPORT_CHANNEL_ID= -ACCOUNT_INFO_CHANNEL_ID= DISCORD_CHANNEL_ID= # --- Discord: Ticket copy & buttons --- @@ -59,10 +58,6 @@ MY_EMAIL= DISCORD_ONLY_PORT=5000 # HEALTHCHECK_HOST= -# --- bOSScord (support cockpit) --- -# BOSSCORD_API_KEY= -# BOSSCORD_CORS_ORIGIN=* - # --- Database (test cluster or local) --- MONGODB_URI= # e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db_test?authSource=broccoli_db_test # MONGODB_DATABASE= diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c471248 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Default mode: reviewer-first + +Default output is **scoped improvement prompts**, not code edits. Output format: + +``` +## [Short title] +**Files:** [files to read/modify] +**Problem:** [1-2 sentences] +**Fix:** [specific instructions — what to change, not how to think about it] +**Verify:** [how to confirm] +``` + +Keep each prompt to a 5–20 minute task; decompose larger issues. + +When the user asks for direct fixes, make them — but still avoid unsolicited refactors, rename sweeps, or cleanup beyond the stated scope. + +## Project +- **broccolini-bot** — Discord ticketing + support bot for Indifferent Broccoli (game hosting). +- **Repo:** `/opt/broccolini-bot/` · **Gitea:** `ssh://git@100.114.205.53:2222/indifferentketchup/broccolini-bot.git` +- **DB:** Self-hosted MongoDB on same host as bot, database `broccoli_db`. Dedicated user per DB. +- **Host port 8892 → container port 5000** (`CONFIG.PORT`, env `DISCORD_ONLY_PORT`). +- **Deploy:** `cd /opt/broccolini-bot && git pull && docker compose up --build -d` · tail: `docker logs broccolini-bot --tail 50 -f` + +## Commands +- `npm start` — run the bot (entry: `broccolini-discord.js`). +- `npm run start:test` — run with `ENV_FILE=.env.test`. +- `npm run start:1p` / `start:test:1p` — inject secrets via 1Password CLI (`op run`). +- `npm run test-mongodb` / `test-mongodb:test` — connectivity probe; no test suite exists. +- No lint step configured. No unit/integration test framework. +- **Verification:** prefer `node --check ` for syntax, and inline `node -e "..."` for behavior. For tightly-coupled modules, stub deps via `Module._resolveFilename` override (see `services/channelQueue.js` tests in session history). + +Many files under `scripts/` are one-shot maintenance utilities (backups, user lookups, transcript mapping). They are not wired into CI or into the bot's runtime. + +## Stack +Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 · googleapis · express-rate-limit · p-queue · dotenv/dotenv-expand. + +## Hard Rules + +1. **CommonJS only.** `require` / `module.exports`. Never `import`. +2. **Read before write.** Never propose or make changes to a file without first reading its current contents. +3. **Route channel operations through `services/channelQueue.js`**: `enqueueSend`, `enqueueRename`, `enqueueMove`, `enqueueDelete` (awaits both rename+send tails before deleting). Bypass sites are tagged `// TODO(queue-migrate):` — grep to find them; migrate incrementally when touching. +4. **Logging is fire-and-forget.** Never `await logSystem/logError/logAutomation/logGmail/...`. Chain `.catch(() => {})` instead. +5. **Use `ChannelType` enum from `discord.js`**, not bare integers (`0`, `4`, `5`, `12`, `15`). +6. **Mongoose schema defaults:** pass function references (`default: Date.now`), never invocations (`default: Date.now()` pins all documents to module-load time). +7. **No unsolicited refactors.** Don't rename, reorganize, or restructure beyond the fix's scope. +8. **Backup before destructive data ops.** Provide the backup command first when the fix touches collections/files. + +## Architecture + +Single Node process. Entry: `broccolini-discord.js`. + +### Startup order +1. Module load: env validation, Discord `Client` created, `interactionCreate` / `messageCreate` listeners registered, `client.login(...)` called. +2. Public Express app (`app`) is defined at module scope with a **503 gate** — any `/api/*` request before `appReady` returns 503. +3. `client.once('ready')` (fires after Discord handshake): connects MongoDB, mounts bOSScord routes on `/api` (only if `BOSSCORD_API_KEY` set), calls `app.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST)`, sets `appReady = true`, then starts all background `setInterval`s. +4. The **internal** Express app (`internalApp`) listens separately on `0.0.0.0:INTERNAL_API_PORT` inside the bot container at module load, guarded by `INTERNAL_API_SECRET`. Not publicly exposed — reachable only from peers on the `broccoli-net` docker network (notably the settings-site container). + +### Two HTTP surfaces +- **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`. +- **Internal (`internalApp`)** — `broccoli-net` only (binds `0.0.0.0` inside the bot container; no host `ports:` publish), `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it. + +`routes/internalApi.js` is required at module scope by `broccolini-discord.js` *before* the parent's `module.exports` populates — reaching back to the parent (e.g., `trackInterval`, `trackTimeout`, `clearGmailPollInterval`) must use a **lazy `require('../broccolini-discord')` inside the handler**, not a top-level destructure. + +### Intervals & shutdown +- Every `setInterval` inside `ready` is wrapped via `trackInterval(...)` into the module-scoped `activeIntervals` Set. +- `handleShutdown(signal)` is idempotent (`shuttingDown` flag): clears every tracked interval, closes both HTTP servers, calls `client.destroy()`, calls `closeMongoDB()`, then `process.exit(0)`. Wired to SIGTERM/SIGINT. +- `setGmailPollInterval(ms)` and `clearGmailPollInterval()` manage the Gmail poll handle and keep it in sync with `activeIntervals`. + +### Interaction error handling +Every `interactionCreate` branch runs through `runHandler(name, interaction, fn)` which catches, `logError`s, and replies ephemerally `'Something went wrong.'` (uses `followUp` when the interaction is already deferred/replied). Setup buttons have their own try/catch for a custom error message. + +### Tickets (`services/tickets.js`, `models.js`) +- `Ticket` schema has indexes on `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`. +- **Discord-originated tickets** use `gmailThreadId` with prefix `discord-` / `discord-msg-` — skip the Gmail reply path entirely. +- Renames route through `utils/renamer.js` (RENAMER_BOT secondary token). On 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`. `canRename()` in `services/tickets.js` is retained as an always-ok shim for back-compat. `Ticket.renameCount` / `Ticket.renameWindowStart` remain in the schema but are now unread/unwritten orphan fields. +- `getOrCreateTicketCategory()` handles Discord's 50-channels-per-category ceiling by creating `" (Overflow N)"` categories; `cleanupEmptyOverflowCategory()` removes empties. The primary category is never deleted. +- Scheduled jobs in `ready`: `checkAutoClose`, `checkAutoUnclaim`, `reconcileDeletedTicketChannels`, plus `services/staffNotifications.js#notifyAllStaffUnclaimed` and the pattern/surge/chat checkers. + +### Gmail bridge (`gmail-poll.js`, `services/gmail.js`) +- Polls `is:unread category:primary`, creates or appends to ticket channels. +- **Auth failure halts polling.** On `invalid_grant` / `unauthorized` / 401: `pollSuspended = true`, the poll interval is cleared via `require('./broccolini-discord').clearGmailPollInterval()`, admin is DM'd once. Polling does **not** auto-retry — container must restart after re-auth. +- `services/gmail.js` exports `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail`, `getGmailClient`. All HTML bodies go through `escapeHtml()`; `Date.now`-derived variables in templates come from `CONFIG` (`TICKET_CLOSE_MESSAGE`, `TICKET_CLOSE_SIGNATURE`, `SUPPORT_NAME`, `LOGO_URL`, `SIGNATURE`, `EMAIL_SIGNATURE`). + +### Pattern / surge / chat (`services/patternStore.js` et al.) +- In-memory counters bucketed into `today` / `week` / `month`, with scheduled resets at midnight / Monday 00:00 / 1st 00:00. +- `escalatingCooldowns` entries carry a `lastUsed` timestamp; a 6-hour interval prunes entries idle for >48h. The cleanup interval is `.unref()`-ed so shutdown isn't blocked by it. + +### `.env` persistence (`services/configPersistence.js`) +- Values are stored in **backtick** containers because dotenv v17 only decodes `\n`/`\r` inside `"…"` (not `\"` or `\\`) — backticks preserve quotes + literal newlines verbatim. `readEnvFile` joins multi-line backtick values; `writeEnvFile` re-reads after write and throws on key-count mismatch. + +## bOSScord integration + +bOSScord is a separate React + Express cockpit app that consumes this bot's `/api/*` endpoints. +- Base URL: `http://100.114.205.53:8892/api` · Bearer `${BOSSCORD_API_KEY}`. +- bOSScord uses its own database (`bosscord_db`) — do not mix models. +- **Response-shape changes on `/api/*` are breaking** for bOSScord. Coordinate or version. + +## Known bad state + +- **Gmail `invalid_grant`** — `REFRESH_TOKEN` is a stale placeholder. Poll suspends automatically on auth error; the rest of the bot still works. Fix by regenerating the token (`node get-refresh-token.js`) and restarting. +- **`STAFF_EMOJIS` encoding** — some emoji entries render malformed. Root cause not identified. +- **Escalation button** — handler misfires in some flows. Root cause not identified. + +Do not re-report these as new findings. + +## Environment highlights + +Names and full tables are in `README.md` / `.env.example`. Ones that commonly trip up new code: + +| Var | Notes | +|-----|-------| +| `DISCORD_TOKEN` **or** `DISCORD_BOT_TOKEN` | First non-empty after trim wins. | +| `DISCORD_ONLY_PORT` | Maps to `CONFIG.PORT` (default 5000). | +| `HEALTHCHECK_HOST` | Omit for all-interfaces; set `127.0.0.1` for local-only. | +| `BOSSCORD_API_KEY` | Without it, `/api/*` is never mounted. | +| `BOSSCORD_CLIENT_ORIGIN` | CORS origin for bOSScord (not `BOSSCORD_CORS_ORIGIN`). | +| `INTERNAL_API_SECRET` | Without it, the internal settings API is never started. | +| `INTERNAL_API_PORT` | Internal app's port (127.0.0.1 bind). | +| `REFRESH_TOKEN` | Gmail OAuth; currently stale — see Known bad state. | + +## Settings site + +`settings-site/` contains a separate Express app (`settings-site/server.js`) for the admin UI — it talks to `internalApp` via `INTERNAL_API_SECRET`. It is **not** part of this bot's process. Changes to the bot's `/internal/config` contract (e.g., the `ALLOWED_CONFIG_KEYS` set) may break the settings UI. See `settings-site/CLAUDE.md` for that subproject's architecture and conventions. + +Its Docker build context is `settings-site/` only — parent-repo files (e.g., `utils.js`) are unreachable inside the container. Any shared helper must be inlined or the build context widened in `docker-compose.yml`. diff --git a/api/bosscordClient.js b/api/botClient.js similarity index 100% rename from api/bosscordClient.js rename to api/botClient.js diff --git a/broccolini-discord.js b/broccolini-discord.js index ff934bd..1daaff2 100644 --- a/broccolini-discord.js +++ b/broccolini-discord.js @@ -11,7 +11,6 @@ const { mongoose } = require('./db-connection'); // Handlers const { handleButton, handleTicketModal } = require('./handlers/buttons'); const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands'); -const { handleSendAccountInfoToChannel, BUTTON_PREFIX } = require('./handlers/accountinfo'); const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup'); const { handleDiscordReply } = require('./handlers/messages'); @@ -19,8 +18,8 @@ const { handleDiscordReply } = require('./handlers/messages'); const { sendTicketClosedEmail } = require('./services/gmail'); const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets'); const { registerCommands } = require('./commands/register'); -const bosscordRoutes = require('./routes/bosscord'); -const { setBot } = require('./api/bosscordClient'); +// 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 { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog'); @@ -114,11 +113,6 @@ async function runHandler(name, interaction, fn) { } client.on('interactionCreate', async interaction => { - if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) { - const handled = await runHandler('handleSendAccountInfoToChannel', interaction, () => handleSendAccountInfoToChannel(interaction)); - if (handled) return; - } - if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) { try { const handled = await handleSetupButton(interaction); @@ -212,13 +206,6 @@ client.once('ready', async () => { await connectMongoDB(process.env.MONGODB_URI); setDebugClient(client); setBot(client); - if (process.env.BOSSCORD_API_KEY) { - app.use('/api', bosscordRoutes); - app.use('/api', (err, req, res, next) => { - console.error('bOSScord API error:', err && err.stack ? err.stack : err); - res.status(500).json({ error: 'Internal server error' }); - }); - } const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined; httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => { console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); diff --git a/commands/register.js b/commands/register.js index d4df42a..70e6666 100644 --- a/commands/register.js +++ b/commands/register.js @@ -463,31 +463,6 @@ async function registerCommands() { .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - new SlashCommandBuilder() - .setName('accountinfo') - .setDescription('Look up website account info by email or Discord user') - .setContexts([InteractionContextType.Guild]) - .setIntegrationTypes([ApplicationIntegrationType.GuildInstall]) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .addSubcommand(sub => - sub - .setName('email') - .setDescription('Look up by email address') - .addStringOption(opt => - opt.setName('email').setDescription('Account email').setRequired(true) - ) - ) - .addSubcommand(sub => - sub - .setName('discord') - .setDescription('Look up by Discord user') - .addUserOption(opt => - opt.setName('user').setDescription('Discord user').setRequired(true) - ) - ), - - - new SlashCommandBuilder() .setName('signature') .setDescription('Set your personal email signature (valediction, display name, tagline)') diff --git a/config.js b/config.js index 9597f2c..f42ea42 100644 --- a/config.js +++ b/config.js @@ -57,7 +57,6 @@ const CONFIG = { LOG_CHAN: process.env.LOGGING_CHANNEL_ID, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null, - ACCOUNT_INFO_CHANNEL_ID: process.env.ACCOUNT_INFO_CHANNEL_ID || null, DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null, CLIENT_ID: process.env.DISCORD_APPLICATION_ID, REFRESH_TOKEN: process.env.REFRESH_TOKEN, @@ -66,7 +65,7 @@ const CONFIG = { SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support', PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000), HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only - SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '
'), + SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'), GAME_LIST: process.env.GAME_LIST || '', DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null, EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null, diff --git a/handlers/accountinfo.js b/handlers/accountinfo.js deleted file mode 100644 index 171af2e..0000000 --- a/handlers/accountinfo.js +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Account info command: look up website User by email or Discord ID, - * show ephemeral embed with option to send transcript to account info channel. - */ -const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); -const { CONFIG } = require('../config'); -const { mongoose } = require('../db-connection'); -const { logSecurity } = require('../services/debugLog'); -const { enqueueSend } = require('../services/channelQueue'); -const { isStaff } = require('../utils'); - -const User = mongoose.model('User'); - -const BUTTON_PREFIX = 'send_account_info_'; -const MAX_CUSTOM_ID_LENGTH = 100; - -function buildAccountInfoEmbed(user, requestedBy = null) { - const embed = new EmbedBuilder() - .setTitle('Account Info') - .setColor(CONFIG.EMBED_COLOR_INFO) - .setTimestamp(); - - embed.addFields({ - name: 'Email', - value: user.email || '*not set*', - inline: true - }); - embed.addFields({ - name: 'Discord ID', - value: user.discordID ? `<@${user.discordID}>` : '*not set*', - inline: true - }); - embed.addFields({ - name: 'Customer ID', - value: user.customerId || '*not set*', - inline: true - }); - - const servers = user.servers || []; - const serverOrder = user.serverOrder || []; - const ordered = serverOrder.length - ? serverOrder.map(id => servers.find(s => s._id && s._id.toString() === id) || servers[serverOrder.indexOf(id)]).filter(Boolean) - : servers; - - if (ordered.length === 0) { - embed.addFields({ - name: 'Servers', - value: '*No servers*', - inline: false - }); - } else { - ordered.forEach((server, i) => { - const n = i + 1; - embed.addFields({ - name: `Server ${n} – Game`, - value: server.game || '*not set*', - inline: true - }); - embed.addFields({ - name: `Server ${n} – IP`, - value: server.ip || '*not set*', - inline: true - }); - embed.addFields({ - name: `Server ${n} – Port`, - value: server.serverPort != null ? String(server.serverPort) : '*not set*', - inline: true - }); - }); - } - - if (requestedBy) { - embed.setFooter({ text: `Requested by ${requestedBy}` }); - } - - return embed; -} - -async function handleAccountInfoCommand(interaction) { - const subcommand = interaction.options.getSubcommand(); - let user = null; - - if (subcommand === 'email') { - const email = (interaction.options.getString('email') || '').trim().toLowerCase(); - if (!email) { - return interaction.reply({ content: 'Please provide an email.', ephemeral: true }); - } - user = await User.findOne({ email }).lean(); - } else if (subcommand === 'discord') { - const target = interaction.options.getUser('user'); - if (!target) { - return interaction.reply({ content: 'Please provide a Discord user.', ephemeral: true }); - } - user = await User.findOne({ discordID: target.id }).lean(); - } - - if (!user) { - return interaction.reply({ - content: subcommand === 'email' ? 'No account found for that email.' : 'No account found for that Discord user/ID.', - ephemeral: true - }); - } - - const identifier = subcommand === 'email' - ? interaction.options.getString('email') - : interaction.options.getUser('user')?.tag || 'unknown'; - logSecurity('Account lookup', interaction.user, `lookup: ${subcommand} → ${identifier}`, null, 0x0099ff).catch(() => {}); - - const embed = buildAccountInfoEmbed(user, interaction.user.tag); - const components = []; - - if (CONFIG.ACCOUNT_INFO_CHANNEL_ID) { - const safeEmail = (user.email || '').slice(0, 50); - const safeDiscordId = (user.discordID || '').slice(0, 50); - const customId = `${BUTTON_PREFIX}discord:${safeDiscordId}`; - if (customId.length <= MAX_CUSTOM_ID_LENGTH) { - components.push( - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(customId) - .setLabel('Send to account info channel') - .setStyle(ButtonStyle.Secondary) - ) - ); - } - } - - await interaction.reply({ - embeds: [embed], - components, - ephemeral: true - }); -} - -async function handleSendAccountInfoToChannel(interaction) { - if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false; - - // Dispatched directly from interactionCreate — no upstream command-level staff gate here, so enforce it. - if (!isStaff(interaction.member)) { - logSecurity('Unauthorized account-info button', interaction.user, `non-staff pressed ${interaction.customId}`, null, 0xff0000).catch(() => {}); - await interaction.reply({ content: 'You do not have permission to do that.', ephemeral: true }).catch(() => {}); - return true; - } - - const payload = interaction.customId.slice(BUTTON_PREFIX.length); - const [type, value] = payload.includes(':') ? payload.split(':') : [payload, '']; - - let user = null; - if (type === 'email') { - const email = Buffer.from(value, 'base64').toString('utf8').toLowerCase(); - user = await User.findOne({ email }).lean(); - } else if (type === 'discord' && value) { - user = await User.findOne({ discordID: value }).lean(); - } - - if (!user) { - await interaction.update({ content: 'Account no longer found.', components: [] }).catch(() => - interaction.followUp({ content: 'Account no longer found.', ephemeral: true }) - ); - return true; - } - - if (!CONFIG.ACCOUNT_INFO_CHANNEL_ID) { - await interaction.update({ content: 'Account info channel is not configured.', components: [] }).catch(() => - interaction.followUp({ content: 'Account info channel is not configured.', ephemeral: true }) - ); - return true; - } - - const channel = await interaction.client.channels.fetch(CONFIG.ACCOUNT_INFO_CHANNEL_ID).catch(() => null); - if (!channel) { - await interaction.update({ content: 'Could not find account info channel.', components: [] }).catch(() => - interaction.followUp({ content: 'Could not find account info channel.', ephemeral: true }) - ); - return true; - } - - const embed = buildAccountInfoEmbed(user, `${interaction.user.tag} (from ticket)`); - await enqueueSend(channel, { embeds: [embed] }); - - await interaction.update({ - content: 'Account info sent to account transcript channel.', - components: [] - }).catch(() => - interaction.followUp({ content: 'Account info sent to account transcript channel.', ephemeral: true }) - ); - return true; -} - -module.exports = { - buildAccountInfoEmbed, - handleAccountInfoCommand, - handleSendAccountInfoToChannel, - BUTTON_PREFIX -}; diff --git a/handlers/buttons.js b/handlers/buttons.js index 49df4e3..d969a60 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -30,7 +30,6 @@ const { logError, logSystem } = require('../services/debugLog'); const Ticket = mongoose.model('Ticket'); const Transcript = mongoose.model('Transcript'); const Tag = mongoose.model('Tag'); -const User = mongoose.model('User'); /** * Main button/modal handler – called from interactionCreate. diff --git a/handlers/commands.js b/handlers/commands.js index b873339..fd18eee 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -21,13 +21,11 @@ const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channel const { setNotifyDm } = require('../services/staffSettings'); const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics'); const { logTicketEvent, logSecurity, logError } = require('../services/debugLog'); -const { handleAccountInfoCommand } = require('./accountinfo'); const { handleSetupCommand } = require('./setup'); const { pendingCloses } = require('./pendingCloses'); const Ticket = mongoose.model('Ticket'); const Tag = mongoose.model('Tag'); -const User = mongoose.model('User'); /** * True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES. @@ -800,12 +798,6 @@ async function handleCommand(interaction) { return; } - // /accountinfo - if (interaction.commandName === 'accountinfo') { - await handleAccountInfoCommand(interaction); - return; - } - // /help if (interaction.commandName === 'help') { const embed = new EmbedBuilder() @@ -818,7 +810,7 @@ async function handleCommand(interaction) { }, { name: 'Ticket Management', - value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic ` - Set ticket topic/description\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user' + value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic ` - Set ticket topic/description' }, { name: 'Saved Responses', diff --git a/models.js b/models.js index 76e0c51..ce921d4 100644 --- a/models.js +++ b/models.js @@ -1,795 +1,5 @@ var mongoose = require('mongoose'); -mongoose.model('Host', new mongoose.Schema({ - hostname: String, - ip: String, - region: String, - provider: String, - memory: String, - status: String, - ipGateway: String, - memFree: Number, - cpuUsage: Number, - diskFree: Number, - lastSeen: { type: Number, default: Date.now }, - lostInUse: { type: [Number], default: [] }, - statsHistory: [{ - timestamp: Number, - memFree: Number, - cpuUsage: Number, - diskFree: Number - }] -})); - -// Update for each new game -mongoose.model('User', new mongoose.Schema({ - email: String, - discordID: {type: String, default: ""}, - customerId: String, - usedPaypal: {type: Boolean, default: false}, - passwordHash: String, - resetPasswordToken: String, - resetPasswordExpires: Date, - sessionToken: {type: String, default: null}, - indifferentBroccoli: {type: Boolean, default: false}, - paymentLink: {type: String, default: null}, - palpocalypseEligible: {type: Boolean, default: false}, - palpocalypseClaimed: {type: Boolean, default: false}, - //Admin - machineStats: [{ - name: String, - memoryFree: Number, - cpuUsagePercentage: Number, - diskFree: Number - }], - - //Subusers - subUserServers: [{ - linuxUsername: String, - permissions: { type: Object, default: {} } - }], - - subusers: [{ - email: String, - inviteToken: String, - inviteExpires: Date, - }], - - // Activity log - activities: [{ - serverId: mongoose.Schema.Types.ObjectId, - action: String, - timestamp: { type: Number, default: Date.now } - }], - - serverOrder: [String], - - servers: [{ - // Public server page info - tags: String, - thumbnailImageLink: String, - links: [String], - - // Server settings - status: {type: String, default: "Setting Up"}, - isTrial: {type: Boolean, default: false}, - trialExpiry: {type: Date, default: null}, - sentExpiryNotification: {type: Boolean, default: false}, - sentTrialEndedNotification: {type: Boolean, default: false}, - sentWelcomeFeedbackRequest: {type: Boolean, default: false}, - sentUpcomingCancellationNotice : {type: Boolean, default: false}, - linuxUsername: String, - linuxPassword: String, //todo: store hash - telnetPassword: String, - controlPanelPassword: String, - subscriptionId: {type:String,default:null}, - subscriptionId_PayPal: {type:String,default:null}, - subscriptionId_PayPalFrozen: {type:String,default:null}, - subscriptionActive: {type: Boolean, default: false}, - subscriptionStatus: {type: String, default: null}, - subscriptionScheduledFreeze: {type: String, default: null}, - subscriptionScheduledFreezeJobId: {type: String, default: null}, - subscriptionScheduledCancel: {type: String, default: null}, - subscriptionScheduledCancelJobId: {type: String, default: null}, - ip: String, - ipGateway: {type: String, default: null}, - serverPort: {type: Number, default: null}, - serverPortGateway: {type: Number, default: null}, - region: {type: String, default: "na-east"}, - game: String, - discordAdmins: {type: [String], default: []}, - // Generic game settings - serverName: String, - serverPassword: String, //todo: store hash? - gameWorld: String, // pre-Alpha 17 - serverDescription: String, - serverMaxPlayerCount: Number, - worldName: String, - - // StarRupture settings - sessionName: {type: String, default: "IndifferentWorld"}, - saveGameInterval: {type: Number, default: 300}, - saveGameName: {type: String, default: "AutoSave0.sav"}, - loadSavedGame: {type: Boolean, default: true}, - - scheduledRestarts: { - type: [{ - command: {type: String, default: "restart"}, - rconCommand: String, - minute: Number, - hour: Number, - day: Number, - intervalValue: Number, - intervalUnit: String, - intervalMinute: Number - }], - default: [] - }, - admins: [{ - steamId: String, - permissionLevel: Number - }], - backups: [{ - timestamp: Number - }], - ActiveMods: [{ - modId: String - }], - playersOnline: [{ - name: String, - raw: { - score: Number, - time: Number - } - }], - - //-----7 Day to Die-----// - serverIsPublic: String, // pre-Alpha 17 - adminPassword: String, //todo: store hash?, - serverWebsiteUrl: String, - gameName: String, - gameDifficulty: Number, - gameMode: String, // don't make the GameModeSurvival or else anyone can place blocks anywhere - zombiesRun: Number, - buildCreate: String, - dayNightLength: Number, - dayLightLength: Number, - playerKillingMode: Number, - persistentPlayerProfiles: String, - playerSafeZoneLevel: Number, - playerSafeZoneHours: Number, - deathPenalty: Number, - dropOnDeath: Number, - dropOnQuit: Number, - bloodMoonEnemyCount: Number, - enemySpawnMode: String, - enemyDifficulty: Number, - blockDurabilityModifier: Number, - lootAbundance: Number, - lootRespawnDays: Number, - landClaimSize: Number, - landClaimDeadZone: Number, - landClaimExpiryTime: Number, - landClaimDecayMode: Number, - landClaimOnlineDurabilityModifier: Number, - landClaimOfflineDurabilityModifier: Number, - airDropFrequency: Number, - airDropMarker: String, - maxSpawnedZombies: Number, - maxSpawnedAnimals: Number, - eacEnabled: String, - maxUncoveredMapChunksPerPlayer: Number, - bedrollDeadZoneSize: Number, - questProgressionDailyLimit: Number, - maxChunkAge: Number, - serverAllowCrossplay: {type: String, default: "false"}, - biomeProgression: {type: String, default: "true"}, - stormFreq: {type: Number, default: 100}, - allowSpawnNearFriend: {type: Number, default: 2}, - ignoreEOSSanctions: {type: String, default: "false"}, - playerCount: Number, - jarRefund: {type: Number, default: 0}, - - // Alpha >= 17 only - version: Number, - serverVisibility: Number, - serverReservedSlots: Number, - serverReservedSlotsPermission: Number, - serverDisabledNetworkProtocols: String, - worldGenSeed: String, - worldGenSize: Number, //todo: figure out max - telnetFailedLoginLimit: Number, // todo: figure out max or don't use - telnetFailedLoginsBlocktime: Number, // todo: figure out max or don't use - terminalWindowEnabled: Boolean, //pre Alpha 17.2 default was false - partySharedKillRange: Number, - hideCommandExecutionLog: Number, - serverLoginConfirmationText: String, - zombieFeralSense: Number, - zombieMove: Number, - zombieMoveNight: Number, - zombieFeralMove: Number, - zombieBMMove: Number, - // Alpha >= 17.2 only - bloodMoonFrequency: Number, - bloodMoonRange: Number, - bloodMoonWarning: Number, - xpMultiplier: Number, - blockDamagePlayer: Number, - blockDamageAI: Number, - blockDamageAIBM: Number, - landClaimCount: Number, - // Alpha >= 18 only - serverMaxAllowedViewDistance: Number, - serverMaxWorldTransferSpeedKiBs: Number, - bedrollExpiryTime: Number, - - sevenDaysRegion: String, - language: String, - - //-----Abiotic Factor-----// - - //-----ARK-----// - BETA: {type: String, default: "public"}, - AdminLogging: Boolean, - AllowCaveBuildingPvE: Boolean, - AllowFlyerCarryPvE: Boolean, - AllowHideDamageSourceFromLogs: Boolean, - AllowSharedConnections: Boolean, - AllowTekSuitPowersInGenesis: Boolean, - allowThirdPersonPlayer: Boolean, - alwaysNotifyPlayerJoined: Boolean, - alwaysNotifyPlayerLeft: Boolean, - AutoSavePeriodMinutes: Number, - bAllowPlatformSaddleMultiFloors: Boolean, - BanListURL: String, - bForceCanRideFliers: Boolean, - ClampResourceHarvestDamage: Boolean, - Cluster: [{ - serverName: String, - gameWorld: String, - clusterId: String, - serverId: String, - serverPort: Number, - serverMaxPlayerCount: Number - }], - CrossARKAllowForeignDinoDownloads: Boolean, - Crossplay: {type: Boolean, default: false}, - NoBattlEye: {type: Boolean, default: false}, - ForceAllowCaveFlyers: {type: Boolean, default: false}, - ShowFloatingDamageText: {type: Boolean, default: false}, - CryopodNerfDamageMult: Number, - CryopodNerfDuration: Number, - CryopodNerfIncomingDamageMultPercent: Number, - CustomDynamicConfigUrl: String, - DayCycleSpeedScale: Number, - DayTimeSpeedScale: Number, - DifficultyOffset: Number, - DinoCharacterFoodDrainMultiplier: Number, - DinoCharacterHealthRecoveryMultiplier: Number, - DinoCharacterStaminaDrainMultiplier: Number, - DinoCountMultiplier: Number, - DinoDamageMultiplier: Number, - DinoResistanceMultiplier: Number, - DisableDinoDecayPvE: Boolean, - DisablePvEGamma: Boolean, - DisableStructureDecayPvE: Boolean, - DisableWeatherFog: Boolean, - EnableCryopodNerf: Boolean, - EnableCryoSicknessPVE: Boolean, - EnablePvPGamma: Boolean, - GameIniSettings: [{ - text: String - }], - globalVoiceChat: Boolean, - HarvestAmountMultiplier: Number, - HarvestHealthMultiplier: Number, - ItemStackSizeMultiplier: Number, - MaxGateFrameOnSaddles: Number, - MaxPlatformSaddleStructureLimit: Number, - MaxPlayers: Number, - MaxStructuresInRange: Number, - MaxTamedDinos: Number, - MaxTributeDinos: Number, - MaxTributeItems: Number, - NightTimeSpeedScale: Number, - noTributeDownloads: Boolean, - PerPlatformMaxStructuresMultiplier: Number, - PlatformSaddleBuildAreaBoundsMultiplier: Number, - PlayerCharacterFoodDrainMultiplier: Number, - PlayerCharacterHealthRecoveryMultiplier: Number, - PlayerCharacterStaminaDrainMultiplier: Number, - PlayerCharacterWaterDrainMultiplier: Number, - PlayerDamageMultiplier: Number, - PlayerResistanceMultiplier: Number, - proximityChat: Boolean, - PvEDinoDecayPeriodMultiplier: Number, - PvEStructureDecayDestructionPeriod: Number, - PvEStructureDecayPeriodMultiplier: Number, - PvPStructureDecay: Boolean, - RandomSupplyCratePoints: Boolean, - ResourcesRespawnPeriodMultiplier: Number, - ServerAdminPassword: String, - serverForceNoHud: Boolean, - serverHardcore: Boolean, - serverPVE: Boolean, - ShowMapPlayerLocation: Boolean, - SpectatorPassword: String, - StructureDamageMultiplier: Number, - StructureResistanceMultiplier: Number, - TamingSpeedMultiplier: Number, - TheMaxStructuresInRange: Number, - TribeNameChangeCooldown: Number, - TributeCharacterExpirationSeconds: Number, - TributeDinoExpirationSeconds: Number, - TributeItemExpirationSeconds: Number, - XPMultiplier: Number, - - gameIni: String, - - //-----Conan Exiles-----// - modList: String, - - //-----Core Keeper-----// - gameID: String, - worldSeed: {type: Number, default: 0}, - worldIndex: {type: Number, default: 0}, - worldMode: {type: Number, default: 0}, - season: {type: Number, default: -1}, - corekeeperMods: {type: String, default: ""}, - - //-----Counter Strike 2 (CS2)-----// - - //-----DayZ-----// - enableWhitelist: { type: Boolean, default: false }, - disable3rdPerson: { type: Boolean, default: false }, - disableCrosshair: { type: Boolean, default: false }, - disablePersonalLight: { type: Boolean, default: false }, - disableVoicechat: { type: Boolean, default: false }, - modList: {type: String, default: ""}, - - //-----ECO-----// - - //-----Enshrouded-----// - - //-----Factorio-----// - spaceAgeEnabled: {type: Boolean, default: true}, - autoUpdateMods: {type: Boolean, default: false}, - visibilityPublic: {type: Boolean, default: true}, - factorioUsername: {type: String, default: ""}, - factorioPassword: {type: String, default: ""}, - factorioToken: {type: String, default: ""}, - requireUserVerification: {type: Boolean, default: true}, - allowCommands: {type: String, default: "admins-only"}, - afkAutokickInterval: {type: Number, default: 0}, - autoPause: {type: Boolean, default: true}, - autoPauseWhenPlayersConnect: {type: Boolean, default: false}, - onlyAdminsCanPause: {type: Boolean, default: true}, - - //-----FiveM-----// - licenseKey: {type: String, default: ""}, - locale: String, - - //-----The Front-----// - extraArgs: String, - - //-----Garry's Mod-----// - workshopCollection: String, - serverCheats: Boolean, - customParameters: String, - GLST: String, - - //-----Hytale-----// - viewDistance: {type: Number, default: 12}, - MaxViewRadius: {type: Number, default: 32}, - serverMOTD: {type: String, default: ""}, - defaultWorld: {type: String, default: "default"}, - selectedWorld: {type: String, default: "default"}, - IsPvpEnabled: {type: Boolean, default: false}, - IsFallDamageEnabled: {type: Boolean, default: true}, - IsGameTimePaused: {type: Boolean, default: false}, - IsSpawningNPC: {type: Boolean, default: true}, - IsSpawnMarkersEnabled: {type: Boolean, default: true}, - IsAllNPCFrozen: {type: Boolean, default: false}, - IsCompassUpdating: {type: Boolean, default: true}, - IsObjectiveMarkersEnabled: {type: Boolean, default: true}, - itemsLossMode: {type: String, default: "Configured"}, - itemsAmountLossPercentage: {type: Number, default: 10}, - itemsDurabilityLossPercentage: {type: Number, default: 10}, - gameMode: {type: String, default: "Adventure"}, - hytaleOAuthUrl: String, - hytaleAuthLinkClicked: {type: Boolean, default: false}, - - //-----Palworld-----// - AutoResetGuildTimeNoOnlinePlayers: {type: Number, default: 72}, - bActiveUNKO: {type: Boolean, default: false}, - BanListURL: {type: String, default: "https://api.palworldgame.com/api/banlist.txt"}, - BaseCampMaxNum: {type: Number, default: 128}, - BaseCampWorkerMaxNum: {type: Number, default: 15}, - bAutoResetGuildNoOnlinePlayers: {type: Boolean, default: false}, - bCanPickupOtherGuildDeathPenaltyDrop: {type: Boolean, default: false}, - bEnableAimAssistKeyboard: {type: Boolean, default: false}, - bEnableAimAssistPad: {type: Boolean, default: true}, - bEnableDefenseOtherGuildPlayer: {type: Boolean, default: false}, - bEnableFastTravel: {type: Boolean, default: true}, - bEnableFriendlyFire: {type: Boolean, default: false}, - bEnableInvaderEnemy: {type: Boolean, default: true}, - bEnableNonLoginPenalty: {type: Boolean, default: true}, - bEnablePlayerToPlayerDamage: {type: Boolean, default: false}, - bExistPlayerAfterLogout: {type: Boolean, default: false}, - bIsMultiplay: {type: Boolean, default: false}, - bIsPvP: {type: Boolean, default: false}, - bIsStartLocationSelectByMap: {type: Boolean, default: true}, - BuildObjectDamageRate: {type: Number, default: 1}, - BuildObjectDeteriorationDamageRate: {type: Number, default: 1}, - bUseAuth: {type: Boolean, default: true}, - CollectionDropRate: {type: Number, default: 1}, - CollectionObjectHpRate: {type: Number, default: 1}, - CollectionObjectRespawnSpeedRate: {type: Number, default: 1}, - CoopPlayerMaxNum: {type: Number, default: 4}, - DayTimeSpeedRate: {type: Number, default: 1}, - DeathPenalty: {type: String, default: "All"}, - Difficulty: {type: String, default: "None"}, - DropItemAliveMaxHours: {type: Number, default: 1}, - DropItemMaxNum: {type: Number, default: 3000}, - DropItemMaxNum_UNKO: {type: Number, default: 100}, - EnemyDropItemRate: {type: Number, default: 1}, - ExpRate: {type: Number, default: 1}, - GuildPlayerMaxNum: {type: Number, default: 20}, - NightTimeSpeedRate: {type: Number, default: 1}, - PalAutoHPRegeneRate: {type: Number, default: 1}, - PalAutoHpRegeneRateInSleep: {type: Number, default: 1}, - PalCaptureRate: {type: Number, default: 1}, - PalDamageRateAttack: {type: Number, default: 1}, - PalDamageRateDefense: {type: Number, default: 1}, - PalEggDefaultHatchingTime: {type: Number, default: 72}, - PalSpawnNumRate: {type: Number, default: 1}, - PalStaminaDecreaceRate: {type: Number, default: 1}, - PalStomachDecreaceRate: {type: Number, default: 1}, - PlayerAutoHPRegeneRate: {type: Number, default: 1}, - PlayerAutoHpRegeneRateInSleep: {type: Number, default: 1}, - PlayerDamageRateAttack: {type: Number, default: 1}, - PlayerDamageRateDefense: {type: Number, default: 1}, - PlayerStaminaDecreaceRate: {type: Number, default: 1}, - PlayerStomachDecreaceRate: {type: Number, default: 1}, - palRegion: {type: String, default: ""}, - WorkSpeedRate: {type: Number, default: 1}, - Community: {type: Boolean, default: false}, - BaseCampMaxNumInGuild : {type: Number, default: 3}, - ConnectPlatform: {type: String, default: "Steam"}, - SupplyDropSpan : {type: Number, default: 180}, - palworldVersion: {type: String, default: "Latest"}, - RandomizerType: {type: String, default: "None"}, - RandomizerSeed: {type: String, default: ""}, - ChatPostLimitPerMinute: {type: Number, default: 10}, - EnablePredatorBossPal: {type: Boolean, default: true}, - BuildObjectHpRate: {type: Number, default: 1}, - Hardcore: {type: Boolean, default: false}, - CharacterRecreateInHardcore: {type: Boolean, default: false}, - PalLost: {type: Boolean, default: false}, - BuildAreaLimit: {type: Boolean, default: false}, - ItemWeightRate: {type: Number, default: 1}, - MaxBuildingLimitNum: {type: Number, default: 0}, - CrossplayPlatforms: {type: String, default: "(Steam,Xbox,PS5,Mac)"}, - AllowGlobalPalboxExport: {type: Boolean, default: true}, - AllowGlobalPalboxImport: {type: Boolean, default: false}, - randomPalLevels: {type: Boolean, default: false}, - equipmentDurabilityDamageRate: {type: Number, default: 1}, - itemContainerForceMarkDirtyInterval: {type: Number, default: 1}, - itemCorruptionMultiplier: {type: Number, default: 1}, - - //-----Project Zomboid-----// - autoRestartEnabled: {type: Boolean, default: false}, - build42Unstable: {type: Boolean, default: false}, - PZVersion: {type: String, default: "41.78.16"}, - - //-----Rust-----// - mapSize: Number, - maxMapSize: Number, - mapSeed: Number, - oxideEnabled: Boolean, - - //-----Valheim-----// - valheimPlusEnabled: Boolean, - valheimPlusFork: {type: String, default: "valheimPlus"}, - - //-----Satisfactory-----// - serverVersion: {type: String, default: "public"}, - satisfactoryAdminPassword: {type: String, default: ''}, - satisfactoryHealth: {type: String, default: ''}, - satisfactoryActiveSession: {type: String, default: ''}, - satisfactoryTechTier: {type: Number, default: 0}, - satisfactoryTickRate: {type: Number, default: 0}, - satisfactoryGameDuration: {type: Number, default: 0}, - satisfactoryActiveSchematic: {type: String, default: ''}, - satisfactoryIsGamePaused: {type: Boolean, default: false}, - - //-----Sons of the Forest-----// - - //-----Soulmask-----// - pvMode: {type: String, default: "pvp"}, - - //-----Terraria-----// - //----Already made/Non-config.json----// - //gameDifficulty: Number - 7days - //WorldGenSize: Number - 7days - MaxSlots: Number, //uses serverMaxPlayerCount - MOTD: String, - secure: Number, - //----Booleans----// - UseServerName: Boolean, - DebugLogs: Boolean, - DisableLoginBeforeJoin: Boolean, - IgnoreChestStacksOnLoad: Boolean, - Autosave: Boolean, - AnnounceSave: Boolean, - SaveWorldOnCrash: Boolean, - SaveWorldOnLastPlayerExit: Boolean, - InfiniteInvasion: Boolean, - SpawnProtection: Boolean, - RangeChecks: Boolean, - HardcoreOnly: Boolean, - MediumCoreOnly: Boolean, - DisableBuild: Boolean, - DisableHardmode: Boolean, - DisableDungeonGuardian: Boolean, - DisableClownBombs: Boolean, - DisableSnowBalls: Boolean, - DisableTombstones: Boolean, - DisableInvisPvP: Boolean, - RegionProtectChests: Boolean, - RegionProtectGemLocks: Boolean, - IgnoreProjUpdate: Boolean, - IgnoreProjKill: Boolean, - AllowCutTilesAndBreakables: Boolean, - AllowIce: Boolean, - AllowCrimsonCreep: Boolean, - AllowCorruptionCreep: Boolean, - AllowHallowCreep: Boolean, - PreventBannedItemSpawn: Boolean, - PreventDeadModification: Boolean, - PreventInvalidPlaceStyle: Boolean, - ForceXmas: Boolean, - ForceHalloween: Boolean, - AllowAllowedGroupsToSpawnBannedItems: Boolean, - AnonymousBossInvasions: Boolean, - RememberLeavePos: Boolean, - KickOnMediumcoreDeath: Boolean, - BanOnMediumCoreDeath: Boolean, - KickOnHardcoreDeath: Boolean, - BanOnHardcoreDeath: Boolean, - EnableWhitelist: Boolean, - EnableIPBans: Boolean, - EnableUUIDBans: Boolean, - EnableBanOnUsernames: Boolean, - KickProxyUsers: Boolean, - RequireLogin: Boolean, - AllowLoginAnyUsername: Boolean, - AllowRegisterAnyUsername: Boolean, - DisableUUIDLogin: Boolean, - KickEmptyUUID: Boolean, - KickOnTilePaintThresholdBroken: Boolean, - KickOnTileLiquidThresholdBroken: Boolean, - KickOnTileKillThresholdBroken: Boolean, - KickOnTilePlaceThresholdBroken: Boolean, - KickOnDamageThresholdBroken: Boolean, - KickOnProjectileThresholdBroken: Boolean, - KickOnHealOtherThresholdBroken: Boolean, - ProjIgnoreShrapnel: Boolean, - DisableSpewLogs: Boolean, - DisableSecondUpdateLogs: Boolean, - EnableGeoIP: Boolean, - DisplayIPToAdmins: Boolean, - EnableChatAboveHeads: Boolean, - //----Numbers----// - ReservedSlots: Number, - InvasionMultiplier: Number, - DefaultSpawnRate: Number, - DefaultMaximumSpawns: Number, - SpawnProtectionRadius: Number, - MaxRangeForDisabled: Number, - StatueSpawn200: Number, - StatueSpawn600: Number, - StatueSpawnWorld: Number, - RespawnSeconds: Number, - RespawnBossSeconds: Number, - MaxHP: Number, - MaxMP: Number, - BombExplosionRadius: Number, - MaximumLoginAttempts: Number, - MinimumPasswordLength: Number, - BCryptWorkFactor: Number, - TilePaintThreshold: Number, - TileKillThreshold: Number, - TilePlaceThreshold: Number, - TileLiquidThreshold: Number, - MaxDamage: Number, - MaxProjDamage: Number, - ProjectileThreshold: Number, - HealOtherThreshold: Number, - //----Strings----// - PvPMode: String, - ForceTime: String, - DefaultRegistrationGroupName: String, - DefaultGuestGroupName: String, - MediumcoreKickReason: String, - MediumcoreBanReason: String, - HardcoreKickReason: String, - HardcoreBanReason: String, - WhitelistKickReason: String, - ServerFullReason: String, - ServerFullNoReservedReason: String, - HashAlgorithm: String, - CommandSpecifier: String, - CommandSilentSpecifier: String, - SuperAdminChatPrefix: String, - SuperAdminChatSuffix: String, - ChatFormat: String, - ChatAboveHeadsFormat: String, - - //-----Minecraft-----// - saveName: String, - enableCommandBlock: Boolean, - allowFlight: Boolean, - iconLink: String, - resourcePackLink: String, - resourcePackLinkSHA1: String, - requireResourcePack: Boolean, - resourcePackPrompt: String, - enforceWhitelist: Boolean, - maxBuildHeight: Number, - allowNether: Boolean, - generateStructures: Boolean, - spawnAnimals: Boolean, - spawnNPCS: Boolean, - spawnMonsters: Boolean, - forceGamemode: Boolean, - enableHardcore: Boolean, - enablePvP: Boolean, - playerIdleTimeout: Number, - serverMaxAllowedViewDistance: Number, - levelType: String, - generatorSettings: String, - enableRcon: Boolean, - rconPassword: String, - broadcastRconToOps: Boolean, - broadcastConsoleToOps: Boolean, - opPermissionLevel: Number, - functionPermissionLevel: Number, - serverType: {type: String, default: "VANILLA"}, - serverTypeVersion: {type: String, default: ""}, - gameDifficultyString: String, - maxPlayers: Number, - modpackurl: String, - modpackName: String, - modpackVersion: String, - mcVersion: {type: String, default: "LATEST"}, - javaVersion: {type: String, default: "latest"}, - maxTickTime: Number, - spawnProtectionRadius: Number, - - selectedMods: [{ - // For Minecraft - name: String, - slug: String, - // For Project Zomboid - title: String, - workshopId: String, - modId: String, - mapFolder: String, - }], - - //-----VRising-----// - vrisingBepInExEnabled: Boolean, - - //-----Icarus-----// - shutdownIfNotJoinedFor: Number, - shutdownIfEmptyFor: Number, - - //-----Vintage Story-----// - serverLanguage: String, - serverWelcomeMessage: String, - whitelistMode: Number, - allowPvp: Boolean, - verifyPlayerAuth: Boolean, - allowFireSpread: Boolean, - allowFallingBlocks: Boolean, - passTimeWhenEmpty: Boolean, - clientConnectionTimeout: Number, - maxChunkRadius: Number, - chatRateLimit: Number, - maxOwnedGroupChannelsPerUser: Number, - seed: String, - allowCreativeMode: Boolean, - playStyle: String, - worldType: String, - mapSizeX: Number, - mapSizeY: Number, - mapSizeZ: Number, - gameMode: String, - startingClimate: String, - spawnRadius: Number, - graceTimer: Number, - deathPunishment: String, - droppedItemsTimer: Number, - seasons: String, - playerlives: Number, - lungCapacity: Number, - daysPerMonth: Number, - harshWinters: Boolean, - blockGravity: String, - caveIns: String, - allowUndergroundFarming: Boolean, - noLiquidSourceTransport: Boolean, - bodyTemperatureResistance: Number, - creatureHostility: String, - creatureStrength: Number, - creatureSwimSpeed: Number, - playerHealthPoints: Number, - playerHungerSpeed: Number, - playerHealthRegenSpeed: Number, - playerMoveSpeed: Number, - foodSpoilSpeed: Number, - saplingGrowthRate: Number, - toolDurability: Number, - toolMiningSpeed: Number, - propickNodeSearchRadius: Number, - microblockChiseling: String, - allowCoordinateHud: Boolean, - allowMap: Boolean, - colorAccurateWorldmap: Boolean, - loreContent: Boolean, - clutterObtainable: String, - lightningFires: Boolean, - allowTimeswitch: Boolean, - temporalStability: Boolean, - temporalStorms: String, - tempstormDurationMul: Number, - temporalRifts: String, - temporalGearRespawnUses: Number, - temporalStormSleeping: Number, - worldClimate: String, - landcover: Number, - oceanscale: Number, - upheavelCommonness: Number, - geologicActivity: Number, - landformScale: Number, - worldWidth: Number, - worldLength: Number, - worldEdge: String, - polarEquatorDistance: Number, - globalTemperature: Number, - globalPrecipitation: Number, - globalForestation: Number, - globalDepositSpawnRate: Number, - surfaceCopperDeposits: Number, - surfaceTinDeposits: Number, - snowAccum: Boolean, - allowLandClaiming: Boolean, - classExclusiveRecipes: Boolean, - auctionHouse: Boolean, - vsVersion: String - }] -})); - -mongoose.model('DashboardMetrics', new mongoose.Schema({ - timestamp: { type: Date, default: Date.now, expires: 31536000 }, - activeUsers: Number, - workerId: String -})); - -mongoose.model('ErrorLog', new mongoose.Schema({ - timestamp: { type: Date, default: Date.now, expires: 2592000 }, // 30 days - statusCode: Number, - message: String, - stack: String, - url: String, - method: String, - userId: String, - userEmail: String, - authenticated: Boolean, - sessionValid: Boolean -})); - // ===== Broccolini Bot Models ===== const ticketSchema = new mongoose.Schema({ diff --git a/routes/bosscord.js b/routes/bosscord.js deleted file mode 100644 index ddc9c08..0000000 --- a/routes/bosscord.js +++ /dev/null @@ -1,239 +0,0 @@ -/** - * bOSScord API routes for broccolini-bot: ticket list/detail, thread from Discord, send message. - * Auth via BOSSCORD_API_KEY. Mount on Express in broccolini-discord.js. - */ -require('../models'); // ensure Ticket model is registered -const express = require('express'); -const mongoose = require('mongoose'); -const rateLimit = require('express-rate-limit'); -const { getBot } = require('../api/bosscordClient'); -const { getGmailClient, sendGmailReply } = require('../services/gmail'); -const { updateTicketActivity } = require('../services/tickets'); -const { enqueueSend } = require('../services/channelQueue'); -const { extractRawEmail, safeEqual } = require('../utils'); -const { CONFIG } = require('../config'); - -const router = express.Router(); -const Ticket = mongoose.model('Ticket'); - -const CORS_ORIGIN = process.env.BOSSCORD_CLIENT_ORIGIN || 'http://100.114.205.53:3081'; - -const apiLimiter = rateLimit({ - windowMs: 60 * 1000, - max: 60, - standardHeaders: true, - legacyHeaders: false, - message: { error: 'Too many requests, please try again later.' } -}); - -function corsMiddleware(req, res, next) { - res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Staff-Discord-Id'); - if (req.method === 'OPTIONS') { - return res.sendStatus(204); - } - next(); -} - -function authMiddleware(req, res, next) { - const key = process.env.BOSSCORD_API_KEY; - if (!key) { - return res.status(503).json({ error: 'bOSScord API not configured (BOSSCORD_API_KEY)' }); - } - const auth = req.headers.authorization; - const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null; - // Identical response body for missing vs invalid token — don't tell a probe which state it's in. - if (!safeEqual(token, key)) { - return res.status(401).json({ error: 'unauthorized' }); - } - next(); -} - -router.use(apiLimiter); -router.use(corsMiddleware); -router.use(authMiddleware); - -function requireDb(req, res, next) { - if (mongoose.connection.readyState !== 1) { - return res.status(503).json({ error: 'Database not ready yet. Wait for the bot to finish starting.' }); - } - next(); -} - -router.use(requireDb); - -function resolveTicketId(id) { - if (mongoose.Types.ObjectId.isValid(id) && String(new mongoose.Types.ObjectId(id)) === id) { - return Ticket.findOne({ _id: id }); - } - const num = parseInt(id, 10); - if (!Number.isNaN(num)) { - return Ticket.findOne({ ticketNumber: num }); - } - return Ticket.findOne({ gmailThreadId: id }); -} - -/** GET /api/tickets — list tickets. Query: status, priority, claimedBy, limit */ -router.get('/tickets', async (req, res, next) => { - try { - if (!Ticket) return res.status(503).json({ error: 'Ticket model not loaded' }); - const { status, priority, claimedBy, limit = 50 } = req.query; - const filter = {}; - if (status) filter.status = status; - if (priority) filter.priority = priority; - if (claimedBy !== undefined && claimedBy !== '') filter.claimedBy = claimedBy === 'null' ? null : claimedBy; - const limitNum = Math.min(parseInt(limit, 10) || 50, 100); - const tickets = await Ticket.find(filter) - .sort({ lastActivity: -1, createdAt: -1 }) - .limit(limitNum) - .lean(); - return res.json({ tickets }); - } catch (err) { - console.error('GET /api/tickets error:', err.message); - console.error(err.stack); - next(err); - } -}); - -/** GET /api/me/tickets — "my tickets" (claimed by staff). Query: X-Staff-Discord-Id or claimedBy */ -router.get('/me/tickets', async (req, res) => { - try { - const claimedBy = req.headers['x-staff-discord-id'] || req.query.claimedBy; - if (!claimedBy) { - return res.status(400).json({ error: 'Provide X-Staff-Discord-Id header or claimedBy query' }); - } - const tickets = await Ticket.find({ claimedBy, status: 'open' }) - .sort({ lastActivity: -1, createdAt: -1 }) - .limit(100) - .lean(); - res.json({ tickets }); - } catch (err) { - console.error('GET /api/me/tickets:', err); - res.status(500).json({ error: err.message }); - } -}); - -/** GET /api/tickets/:id — single ticket metadata */ -router.get('/tickets/:id', async (req, res) => { - try { - const ticket = await resolveTicketId(req.params.id); - if (!ticket) { - return res.status(404).json({ error: 'Ticket not found' }); - } - const out = ticket.toObject ? ticket.toObject() : { ...ticket }; - if (CONFIG.DISCORD_GUILD_ID) out.guildId = CONFIG.DISCORD_GUILD_ID; - res.json(out); - } catch (err) { - console.error('GET /api/tickets/:id:', err); - res.status(500).json({ error: err.message }); - } -}); - -/** GET /api/tickets/:id/messages — thread from Discord */ -router.get('/tickets/:id/messages', async (req, res) => { - try { - const ticket = await resolveTicketId(req.params.id); - if (!ticket) { - return res.status(404).json({ error: 'Ticket not found' }); - } - if (!ticket.discordThreadId) { - return res.json({ messages: [] }); - } - const client = getBot(); - if (!client) { - return res.status(503).json({ error: 'Discord client not ready' }); - } - const limit = Math.min(parseInt(req.query.limit, 10) || 100, 100); - const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null); - if (!channel) { - return res.status(404).json({ error: 'Discord channel not found' }); - } - const messages = await channel.messages.fetch({ limit }); - const list = messages - .sort((a, b) => a.createdTimestamp - b.createdTimestamp) - .map((m) => ({ - id: m.id, - author: m.author?.username || 'unknown', - authorId: m.author?.id, - content: m.content, - timestamp: m.createdAt?.toISOString?.(), - isBot: m.author?.bot ?? false - })); - res.json({ messages: list }); - } catch (err) { - console.error('GET /api/tickets/:id/messages:', err); - res.status(500).json({ error: err.message }); - } -}); - -/** POST /api/tickets/:id/messages — send message to Discord; for email tickets, also send via Gmail */ -router.post('/tickets/:id/messages', express.json(), async (req, res) => { - try { - const ticket = await resolveTicketId(req.params.id); - if (!ticket) { - return res.status(404).json({ error: 'Ticket not found' }); - } - if (!ticket.discordThreadId) { - return res.status(400).json({ error: 'Ticket has no Discord channel' }); - } - const content = req.body?.content; - if (!content || typeof content !== 'string') { - return res.status(400).json({ error: 'Body must include content (string)' }); - } - const client = getBot(); - if (!client) { - return res.status(503).json({ error: 'Discord client not ready' }); - } - const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null); - if (!channel) { - return res.status(404).json({ error: 'Discord channel not found' }); - } - const discordUser = req.body.displayName || 'bOSScord'; - // Content originates from the bOSScord web UI (staff-gated) but still crosses an HTTP boundary — - // allow explicit user/role mentions a staff member typed, block @everyone/@here. - await enqueueSend(channel, { content, allowedMentions: { parse: ['users', 'roles'] } }); - - if (!ticket.gmailThreadId.startsWith('discord-')) { - try { - const gmail = getGmailClient(); - const thread = await gmail.users.threads.get({ - userId: 'me', - id: ticket.gmailThreadId - }); - const last = [...(thread.data.messages || [])].reverse().find((msg) => { - const from = msg.payload?.headers?.find((h) => h.name === 'From')?.value || ''; - return !from.toLowerCase().includes(CONFIG.MY_EMAIL); - }); - if (last?.payload?.headers) { - let recipient = last.payload.headers.find((h) => h.name === 'From')?.value || ''; - const replyTo = last.payload.headers.find((h) => h.name === 'Reply-To')?.value; - if (replyTo) recipient = replyTo; - const subject = last.payload.headers.find((h) => h.name === 'Subject')?.value || 'Support'; - const msgId = last.payload.headers.find((h) => h.name === 'Message-ID')?.value; - const recipientEmail = extractRawEmail(recipient).toLowerCase(); - if (recipientEmail && recipientEmail !== CONFIG.MY_EMAIL) { - await sendGmailReply( - ticket.gmailThreadId, - content, - recipientEmail, - subject, - discordUser, - msgId - ); - } - } - } catch (e) { - console.error('bOSScord Gmail reply error:', e); - } - } - - await updateTicketActivity(ticket.gmailThreadId); - res.status(201).json({ ok: true }); - } catch (err) { - console.error('POST /api/tickets/:id/messages:', err); - res.status(500).json({ error: err.message }); - } -}); - -module.exports = router; diff --git a/routes/internalApi.js b/routes/internalApi.js index b37a02a..beddf0f 100644 --- a/routes/internalApi.js +++ b/routes/internalApi.js @@ -69,7 +69,7 @@ router.post('/config', express.json(), async (req, res) => { // GET /discord/guild — return guild info for smart dropdowns router.get('/discord/guild', async (req, res) => { try { - const client = require('../api/bosscordClient').getBot(); + const client = require('../api/botClient').getBot(); if (!client) return res.status(503).json({ error: 'Bot not ready' }); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); diff --git a/services/configSchema.js b/services/configSchema.js index 1b36333..c4b27e1 100644 --- a/services/configSchema.js +++ b/services/configSchema.js @@ -30,7 +30,7 @@ const ALLOWED_CONFIG_KEYS = new Set([ 'ADMIN_ID', // Channel IDs 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', - 'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID', + 'BACKUP_EXPORT_CHANNEL_ID', 'DISCORD_CHANNEL_ID', 'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID', 'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID', // Messages and labels diff --git a/services/gmail.js b/services/gmail.js index b0d9857..5a91ceb 100644 --- a/services/gmail.js +++ b/services/gmail.js @@ -84,7 +84,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) { const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || ''); const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '
'); - const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '
'); + const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '
'); const htmlBody = `

From: ${serverDisplayName} on Discord

@@ -185,7 +185,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '
'); const serverDisplayName = label; const safeCloseMessage = safeBody; - const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '
'); + const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '
'); const htmlBody = `

From: ${serverDisplayName} on Discord