mvp & email signature
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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=
|
||||
|
||||
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal file
@@ -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 <file>` 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 `"<name> (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`.
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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, '<br>'),
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <text>` - 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 <text>` - Set ticket topic/description'
|
||||
},
|
||||
{
|
||||
name: 'Saved Responses',
|
||||
|
||||
790
models.js
790
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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,7 +84,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
@@ -185,7 +185,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const serverDisplayName = label;
|
||||
const safeCloseMessage = safeBody;
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
|
||||
Reference in New Issue
Block a user