Compare commits
30 Commits
6d579207f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a388d99fdf | |||
| 3212004fc9 | |||
| a565450e2d | |||
| 837fd10984 | |||
| 2152544d09 | |||
| c79463fc2a | |||
| e8e114e4ad | |||
| 452f005aea | |||
| 76279b703a | |||
| 3c13e55dad | |||
| 3e9ad658d0 | |||
| 952b22ac12 | |||
| d89ac65823 | |||
| adcd9dd9c9 | |||
| d0cf8fd915 | |||
| cdf85f6364 | |||
| e3b3b8d48c | |||
| 3ac23466b2 | |||
| 83b6b4ae0c | |||
| 840b6bfcf8 | |||
| d5547e5eea | |||
| 602c6c0191 | |||
| 6b94791813 | |||
| d1e1408256 | |||
| 8e362c607d | |||
| 410f8b043e | |||
| 2a04e3964c | |||
| 60c302276b | |||
| ce62b7a94a | |||
| f3ee27ed7a |
17
.env.example
17
.env.example
@@ -13,13 +13,9 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs
|
||||
# Ticket creation: set one or both; /panel and /email-routing choose behavior
|
||||
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
|
||||
TICKET_CATEGORY_ID= # Category for email-originated ticket channels
|
||||
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
||||
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
||||
|
||||
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
|
||||
# Category display name (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
|
||||
TICKET_CATEGORY_NAME=Open Tickets
|
||||
TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
|
||||
TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
|
||||
|
||||
# Escalation categories (tier 2 and tier 3)
|
||||
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
|
||||
@@ -34,7 +30,6 @@ ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also
|
||||
TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
|
||||
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
|
||||
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
|
||||
DISCORD_CHANNEL_ID= # General Discord channel (if used)
|
||||
|
||||
# --- Discord: Ticket copy & buttons ---
|
||||
# ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME
|
||||
@@ -52,8 +47,7 @@ GOOGLE_CLIENT_SECRET= # OAuth2 Client Secret
|
||||
REFRESH_TOKEN= # OAuth2 refresh token for the support inbox
|
||||
MY_EMAIL= # Support inbox email address
|
||||
|
||||
# --- Server & URLs ---
|
||||
NGROK_URL= # Public URL (optional); run ngrok outside this repo
|
||||
# --- Server ---
|
||||
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
|
||||
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
|
||||
|
||||
@@ -76,7 +70,6 @@ DISCORD_AUTO_CLOSE_MESSAGE= # Message in ticket when auto-closed (e.g. ...
|
||||
|
||||
# --- Ticket limits & permissions ---
|
||||
GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally
|
||||
TICKET_LIMIT_PER_CATEGORY=3 # Max tickets per category
|
||||
RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disabled)
|
||||
RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit
|
||||
BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets
|
||||
@@ -85,7 +78,6 @@ ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissi
|
||||
# --- Auto-close ---
|
||||
AUTO_CLOSE_ENABLED=false
|
||||
AUTO_CLOSE_AFTER_HOURS=72
|
||||
AUTO_CLOSE_MESSAGE= # Message when ticket is auto-closed
|
||||
|
||||
# --- Reminders ---
|
||||
REMINDER_ENABLED=false
|
||||
@@ -137,16 +129,11 @@ SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (
|
||||
INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally)
|
||||
INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string)
|
||||
|
||||
# --- Thread-style tickets (legacy) ---
|
||||
USE_THREADS=false
|
||||
THREAD_PARENT_CHANNEL=
|
||||
|
||||
# --- Game list (comma-separated; used for detection and tags) ---
|
||||
GAME_LIST=Project Zomboid, Minecraft, ...
|
||||
|
||||
# --- Embed colors (hex with 0x prefix) ---
|
||||
EMBED_COLOR_OPEN=0x00FF00
|
||||
EMBED_COLOR_CLOSED=0xFF0000
|
||||
EMBED_COLOR_CLAIMED=0xFFFF00
|
||||
EMBED_COLOR_ESCALATED=0xFF6600
|
||||
EMBED_COLOR_INFO=0x1e2124
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,8 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Documentation: docs/ is committed (all .md except README.md live in docs/)
|
||||
|
||||
# Environment / Secrets (keep .env.example and .env.test.example committed; never commit .env or .env.test)
|
||||
.env
|
||||
.env.*
|
||||
@@ -49,9 +47,6 @@ cursor.yml
|
||||
*.local.yml
|
||||
|
||||
.claude/
|
||||
*.bak
|
||||
*.bak-*
|
||||
|
||||
*.bak
|
||||
*.bak-*
|
||||
CLAUDE.md
|
||||
*.bak*
|
||||
|
||||
120
HOWITWORKS.md
Normal file
120
HOWITWORKS.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# How it works
|
||||
|
||||
Broccolini Bot is a single Node.js process. It does three things at once:
|
||||
|
||||
1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages.
|
||||
2. **Polls Gmail** — every N seconds, pulls unread `category:primary` mail and turns each thread into a Discord ticket channel.
|
||||
3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API.
|
||||
|
||||
State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API.
|
||||
|
||||
---
|
||||
|
||||
## Startup
|
||||
|
||||
`broccolini-discord.js` is the entry point. The order matters:
|
||||
|
||||
1. **Module load** — env validation, Discord `Client` is constructed, `interactionCreate` and `messageCreate` listeners are registered, `client.login()` is called.
|
||||
2. The **public Express app** is defined at module scope. It has a 503 gate — any `/api/*` request before the bot is ready returns 503. (No `/api/*` routes are mounted in the MVP, so it's dormant.)
|
||||
3. The **internal Express app** binds at module load on `INTERNAL_API_PORT` (`0.0.0.0` inside the container, not host-published). Reachable only from peers on the `broccoli-net` Docker network. Auth header: `x-internal-secret`.
|
||||
4. **`client.once('ready')`** — once Discord finishes its handshake the bot connects MongoDB, starts the public HTTP listener, registers slash commands with Discord's REST API, then starts the Gmail poll plus optional `setInterval`s for auto-close, auto-unclaim, sweeping orphan ticket channels, and a 6-hour Tickets sweep.
|
||||
|
||||
Every `setInterval` in the `ready` block is wrapped through `trackInterval(...)` into a module-level `Set`. `handleShutdown` (SIGTERM/SIGINT) clears all of them, closes both HTTP servers, calls `client.destroy()`, then closes Mongo.
|
||||
|
||||
---
|
||||
|
||||
## Email → Discord (the main flow)
|
||||
|
||||
`gmail-poll.js`:
|
||||
|
||||
1. Lists unread messages in `category:primary`.
|
||||
2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document.
|
||||
3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`.
|
||||
4. On subsequent emails in the same thread, just appends the new message to the existing channel.
|
||||
|
||||
**Auth failure halts polling.** On `invalid_grant` / `unauthorized` / 401, `pollSuspended` flips to true, the poll interval is cleared, and the admin is DM'd once. The bot does not retry — fix the token and restart the container.
|
||||
|
||||
---
|
||||
|
||||
## Discord → Gmail (replies)
|
||||
|
||||
`handlers/messages.js` handles `messageCreate` in ticket channels:
|
||||
|
||||
- If the ticket's `gmailThreadId` starts with `discord-` or `discord-msg-`, it's a Discord-only ticket — skip Gmail entirely.
|
||||
- Otherwise, the staff message is forwarded to the customer via Gmail (threaded reply) using `services/gmail.js`. The staffer's per-user signature (`StaffSignature`) is appended.
|
||||
|
||||
Customer replies coming back via email are picked up by the next Gmail poll and appended to the existing ticket channel.
|
||||
|
||||
---
|
||||
|
||||
## Discord-only tickets
|
||||
|
||||
Two paths create them:
|
||||
|
||||
- **`/panel`** posts an "Open ticket" button. Clicking it opens a modal asking for email, game, and description. The bot creates a channel and a `Ticket` with a synthetic `gmailThreadId` like `discord-<channelId>`.
|
||||
- **Context menu "Create Ticket From Message"** does the same, prefilled from the source message (`gmailThreadId` like `discord-msg-<msgId>`).
|
||||
|
||||
Replies in these channels stay in Discord. No Gmail traffic.
|
||||
|
||||
---
|
||||
|
||||
## Channel rename / move queue
|
||||
|
||||
Discord rate-limits channel renames at **2 per 10 minutes per channel**. All channel ops route through `services/channelQueue.js`:
|
||||
|
||||
- `enqueueRename`, `enqueueSend`, `enqueueMove`, `enqueueDelete` — per-channel chained promises. Delete waits for both rename and send tails to drain.
|
||||
- Renames go through `utils/renamer.js`, which uses the `RENAMER_BOT` secondary token. On 401/403/429 from the secondary, the queue falls back to the primary bot's `channel.setName`.
|
||||
- Bypass sites (direct `channel.send` / `setName`) are tagged `// TODO(queue-migrate):` — grep to find them; they get migrated incrementally when touched.
|
||||
|
||||
Logging helpers in `services/debugLog.js` are **fire-and-forget** — `.catch(() => {})`, never `await`. They post to the configured log channels (system, automation, error, gmail, etc.).
|
||||
|
||||
---
|
||||
|
||||
## HTTP surfaces
|
||||
|
||||
**Public** (`app`, port `CONFIG.PORT`, default `5000`):
|
||||
- `GET /` → `Active` once ready, `Starting` before. Used by Docker `HEALTHCHECK`.
|
||||
- `/api/*` is gated behind `appReady` and currently unmounted.
|
||||
|
||||
**Internal** (`internalApp`, `INTERNAL_API_PORT`, `broccoli-net` only, header auth):
|
||||
- `GET /config`, `POST /config` — read/write a strict allowlist (`ALLOWED_CONFIG_KEYS` in `services/configSchema.js`). Unknown keys → 400.
|
||||
- `GET /discord/guild` — basic guild info for the settings UI.
|
||||
- `POST /restart`, `GET /restart/status` — exits the process so the container supervisor restarts it.
|
||||
- `POST /gmail/reload` — reloads the Gmail client after credential changes.
|
||||
|
||||
`.env` writes go through `services/configPersistence.js`, which stores values in **backtick** containers because dotenv v17 only decodes `\n`/`\r` inside double-quoted strings.
|
||||
|
||||
---
|
||||
|
||||
## Storage
|
||||
|
||||
MongoDB, one database, accessed via Mongoose. Models live in `models.js`:
|
||||
|
||||
| Collection | What it stores |
|
||||
|------------|----------------|
|
||||
| `Ticket` | One per email thread or Discord-only ticket. Tracks `gmailThreadId`, `discordThreadId`, status, claimer, priority, escalation tier, ticket tag, last activity, etc. |
|
||||
| `TicketCounter` | Per-sender local counter for ticket numbering. |
|
||||
| `Transcript` | Closed ticket → transcript message pointer. |
|
||||
| `Tag` | Saved-response templates (`/response`). |
|
||||
| `StaffSettings` | Per-user `notifyDm` preference. |
|
||||
| `StaffSignature` | Per-user email signature (valediction, display name, tagline). |
|
||||
|
||||
The `Ticket` schema indexes `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`.
|
||||
|
||||
---
|
||||
|
||||
## Background jobs (in `ready`)
|
||||
|
||||
| Job | Cadence | Toggle |
|
||||
|-----|---------|--------|
|
||||
| Gmail poll | `GMAIL_POLL_INTERVAL_SECONDS` (default 30s; runtime-tunable via `/gmailpoll`) | always |
|
||||
| `checkAutoClose` | configurable | `AUTO_CLOSE_ENABLED` |
|
||||
| `checkAutoUnclaim` | configurable | `AUTO_UNCLAIM_ENABLED` |
|
||||
| `reconcileDeletedTicketChannels` | hourly + on startup | always |
|
||||
| `services/tickets.startTicketsSweeps` | 6h, `.unref()`-ed | always |
|
||||
|
||||
---
|
||||
|
||||
## Settings UI (separate process)
|
||||
|
||||
`settings-site/` is its own small Express app with its own `package.json`, `Dockerfile`, and `CLAUDE.md`. It serves a password-protected dashboard that POSTs config changes to this bot's internal API using `INTERNAL_API_SECRET`. Any change to `ALLOWED_CONFIG_KEYS` here can break the UI there — keep them in sync.
|
||||
555
README.md
555
README.md
@@ -1,521 +1,90 @@
|
||||
# Broccolini Bot
|
||||
|
||||
A **Node.js** Discord bot that unifies **Gmail**, **Discord**, and **MongoDB** for support ticketing. Incoming emails become Discord ticket channels; staff messages in those channels are sent back to customers via Gmail. Discord-originated tickets (panels, context menu) live entirely in Discord. State is stored in MongoDB via Mongoose.
|
||||
A Node.js Discord bot that bridges **Gmail ↔ Discord** for support ticketing, with **MongoDB** for state. Built for Indifferent Broccoli (game-server hosting).
|
||||
|
||||
Built for game-server hosting support (Indifferent Broccoli), with game detection from email content, tiered escalation, optional **per-staff notification channels** (reply alerts with cooldown + unclaimed-ticket digests), saved responses, `/tag` categorization, claimer emoji in channel names (`STAFF_EMOJIS`), and automation (auto-close, reminders, auto-unclaim, claim timeout).
|
||||
- Inbound email → Discord ticket channel.
|
||||
- Staff messages in that channel → Gmail reply (threaded).
|
||||
- Discord-originated tickets (panel / context menu) live entirely in Discord.
|
||||
|
||||
**Jump to:** [Features](#features) · [Quick start](#quick-start) · [Configuration](#configuration) · [Staff notifications](#staff-notification-channels--reply-alerts) · [Broccolini settings page](#broccolini-settings-page) · [Commands](#discord-commands) · [Project layout](#project-structure)
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Architecture](#architecture)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick start](#quick-start)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Staff notification channels & reply alerts](#staff-notification-channels--reply-alerts)
|
||||
- [Broccolini settings page](#broccolini-settings-page)
|
||||
- [Running the bot (test and Docker)](#running-the-bot-test-and-docker)
|
||||
- [Discord commands](#discord-commands)
|
||||
- [Ticket UI (buttons & modals)](#ticket-ui-buttons--modals)
|
||||
- [Tag & response system](#tag--response-system)
|
||||
- [Panel system](#panel-system)
|
||||
- [Channel renames & moves (rate limits)](#channel-renames--moves-rate-limits)
|
||||
- [Project structure](#project-structure)
|
||||
- [Database collections](#database-collections)
|
||||
- [HTTP: healthcheck & optional API](#http-healthcheck--optional-api)
|
||||
- [Gmail OAuth refresh token](#gmail-oauth-refresh-token)
|
||||
- [Documentation in `docs/`](#documentation-in-docs)
|
||||
- [Development & CI](#development--ci)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [References](#references)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Email → Discord
|
||||
|
||||
- Polls Gmail about every **30 seconds** for new unread **primary** mail (`gmail-poll.js`).
|
||||
- Creates a **Discord text channel** (or thread, per guild settings) per email ticket, with overflow category support when a category is full.
|
||||
- Detects **game** from subject/body using `GAME_LIST` and built-in aliases in `config.js`.
|
||||
- Posts welcome + action **buttons** (Close, Claim, Escalate / De-escalate where applicable).
|
||||
|
||||
### Discord → Gmail
|
||||
|
||||
- For **email-sourced** tickets, staff messages in the ticket channel are **forwarded** to the customer via Gmail (threaded).
|
||||
- **Discord-only** tickets (`gmailThreadId` prefix `discord-` / `discord-msg-`) do not use Gmail for replies; conversation stays in Discord.
|
||||
|
||||
### Ticket management
|
||||
|
||||
- **Claim / Unclaim** via buttons (not slash commands); optional claim overwrite, **claim timeout**, and auto-unclaim.
|
||||
- **Priority** (`low` / `normal` / `medium` / `high`) with configurable emojis and `/priority`.
|
||||
- **Escalation**: tier 2 and tier 3 categories (separate IDs for email vs Discord where configured); slash `/escalate` and in-channel buttons.
|
||||
- **De-escalation** one step at a time (`/deescalate` or button).
|
||||
- **Close** with confirmation; **force-close** for admins.
|
||||
- **Transcripts** posted to a configured channel; closure email for email tickets.
|
||||
- **Auto-close**, **inactivity reminders**, **auto-unclaim** (all optional via env).
|
||||
|
||||
### Staff notifications & alerts (optional)
|
||||
|
||||
- **Per-staff notification channels**: **`/notification add`** creates a **dedicated text channel** per staff member under `STAFF_NOTIFICATION_CATEGORY_ID` and stores it in **`StaffNotification`** (MongoDB). When a **non-staff** user replies in a ticket claimed by someone with a notification channel, the bot posts an alert there (subject to **per-ticket cooldown** via `/notification set` or admin **`/staffnotification`**).
|
||||
- **Unclaimed digests**: a background job runs **every 30 minutes** and, if `UNCLAIMED_REMINDER_THRESHOLDS` is set, posts **unclaimed ticket** digests to those same channels when tickets cross age thresholds.
|
||||
- **DM reply alerts**: **`/notifydm`** toggles optional **DM** alerts to the claimer on customer reply (separate from the notification channel); stored in **`StaffSettings`**.
|
||||
- **Staff threads** (optional): when `STAFF_THREAD_ENABLED` is true, each ticket channel can get a private **staff-only thread** named `STAFF_THREAD_NAME`; on claim, the claimer can be added to that thread, and (optionally) all members of `STAFF_THREAD_ROLE_ID` are auto-added.
|
||||
- **Pins** (optional): `PIN_INITIAL_MESSAGE_ENABLED` and `PIN_ESCALATION_MESSAGE_ENABLED` enable auto-pinning of the ticket welcome message and escalation messages; `PIN_SUPPRESS_SYSTEM_MESSAGE` hides the default “X pinned a message” system notice.
|
||||
- **Chat monitoring & surge detection**: see [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for automatic alerts about busy chats, surging games, backlogs, and no-staff situations.
|
||||
|
||||
See [Staff notification channels](#staff-notification-channels--reply-alerts) and [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for details.
|
||||
|
||||
**Note:** Older docs referred to per-staffer **mirror** channels driven by `STAFF_CATEGORIES`. In current `config.js` that map is **deprecated and always empty**, and **`createStaffChannel` is not called** from the claim flow—**`staffChannelId` on tickets is effectively unused.** Reply alerts use **`StaffNotification`** channels instead, and staff discussion happens in optional **staff threads**.
|
||||
|
||||
### Extras
|
||||
|
||||
- **`/panel`**: “Open ticket” UI (modal collects email, game, description).
|
||||
- **`/tag`**: ticket category dropdown; **`/response`**: saved templates with variable substitution.
|
||||
- **`/setup`**: setup wizard for guild defaults.
|
||||
- **`/accountinfo`**: website account lookup (email or Discord user).
|
||||
- **`/stats`**, **`/search`**, **`/backup`**, **`/export`**, **`/email-routing`**.
|
||||
- **Context menus**: create ticket from message; view user tickets.
|
||||
- **Optional REST API** under `/api` when the relevant API key env vars are set (see `.env.example`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BROCCOLINI BOT │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Gmail (inbox) ──► gmail-poll.js ──► Discord ticket channels │
|
||||
│ │ ▲ │
|
||||
│ ▼ │ │
|
||||
│ services/gmail.js ◄── handlers/messages.js │
|
||||
│ services/tickets.js handlers/buttons.js │
|
||||
│ services/channelQueue.js │
|
||||
│ services/staffNotifications.js │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ MongoDB (Mongoose) ◄── models.js │
|
||||
│ │
|
||||
│ Express: GET / → "Active" ; optional /api → routes/ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Typical email ticket lifecycle**
|
||||
|
||||
1. New unread mail → poll creates Discord channel + `Ticket` document.
|
||||
2. Staff reply in channel → message handler sends Gmail reply (email tickets only).
|
||||
3. Close confirmed → transcript, optional closure email, channel delete; DB marked closed.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|-------------|--------|
|
||||
| **Node.js** | **18+**; Docker image uses **20** (`Dockerfile`). |
|
||||
| **npm** | `npm install` locally; `npm ci --omit=dev` in Docker. |
|
||||
| **MongoDB** | Self-hosted; `MONGODB_URI` required at startup. |
|
||||
| **Discord application** | Bot token, application ID; intents: **Message Content**, **Server Members**; also Guilds + Guild Messages. |
|
||||
| **Google Cloud** | Gmail API enabled; OAuth2 client ID/secret + refresh token for the support mailbox. |
|
||||
For an architectural overview, see [HOWITWORKS.md](HOWITWORKS.md). For agent/contributor conventions, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
git clone <repo-url>
|
||||
cd broccolini-bot
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# fill DISCORD_TOKEN, DISCORD_APPLICATION_ID, DISCORD_GUILD_ID, TICKET_CATEGORY_ID,
|
||||
# ROLE_ID_TO_PING, MONGODB_URI, GOOGLE_CLIENT_ID/SECRET, REFRESH_TOKEN, MY_EMAIL
|
||||
npm start
|
||||
```
|
||||
|
||||
1. Fill **Discord** (`DISCORD_TOKEN` or `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID`, categories, `ROLE_ID_TO_PING`, transcript/log channels).
|
||||
2. Fill **MongoDB** (`MONGODB_URI`).
|
||||
3. Fill **Google** OAuth (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `REFRESH_TOKEN`, `MY_EMAIL`) — use `node get-refresh-token.js` once if needed.
|
||||
4. Run `npm start`.
|
||||
5. In Discord, use **`/setup`** or verify categories and roles manually.
|
||||
Need a Gmail refresh token? `node get-refresh-token.js` (redirect URI `http://localhost:3000/oauth2callback`). Probe Mongo with `npm run test-mongodb`.
|
||||
|
||||
Restart after **any** `.env` change. After changing **slash command definitions**, restart so **`registerCommands()`** re-registers with Discord.
|
||||
Restart the bot after any `.env` change. Restart also re-registers slash commands.
|
||||
|
||||
---
|
||||
## Deploy (Docker)
|
||||
|
||||
## Installation
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker logs broccolini --tail 50 -f
|
||||
```
|
||||
|
||||
Same as quick start. Optional:
|
||||
|
||||
- **Test env:** copy `.env.test.example` → `.env.test`. On **Unix shells**: `npm run start:test` (sets `ENV_FILE`). On **Windows PowerShell**: `$env:ENV_FILE='.env.test'; node broccolini-discord.js` (or set `ENV_FILE` in the environment your process manager uses). **`npm run test-mongodb:test`** has the same `ENV_FILE` pattern.
|
||||
- **1Password CLI:** `npm run start:1p` / `start:test:1p` inject secrets (see [docs/setup/1PASSWORD.md](docs/setup/1PASSWORD.md)).
|
||||
|
||||
**Do not commit** `.env` or `.env.test`. AI/agents should not edit production `.env` without explicit approval; see [ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md).
|
||||
|
||||
---
|
||||
Host port `8892` → container `5000` (`DISCORD_ONLY_PORT`).
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is **environment variables** only, loaded in [`config.js`](config.js) as `CONFIG`. Names below match `.env.example` unless noted.
|
||||
|
||||
### Discord (core)
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `DISCORD_TOKEN` | Yes* | Bot token (*or `DISCORD_BOT_TOKEN`, first non-empty wins after trim). |
|
||||
| `DISCORD_APPLICATION_ID` | Yes | Used as `CLIENT_ID` for REST command registration. |
|
||||
| `DISCORD_GUILD_ID` | Yes | Guild where slash commands are registered. |
|
||||
| `TICKET_CATEGORY_ID` | Yes | Default category for **email** tickets (startup validates this). |
|
||||
| `DISCORD_TICKET_CATEGORY_ID` | No | Category for **Discord** panel/context tickets (falls back to `TICKET_CATEGORY_ID`). |
|
||||
| `EMAIL_THREAD_CHANNEL_ID` / `DISCORD_THREAD_CHANNEL_ID` | No | Parent **text** channels for thread-style tickets, when used. |
|
||||
| `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated extra categories when the main is full (50 channels). |
|
||||
| `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS` | No | Same for Discord ticket category. |
|
||||
| `ROLE_ID_TO_PING` | Yes | Support role pinged on new tickets; alias `ROLE_TO_PING_ID` in code. |
|
||||
| `ADDITIONAL_STAFF_ROLES` | No | Extra role IDs treated as staff for commands. |
|
||||
| `BLACKLISTED_ROLES` | No | Roles blocked from opening tickets. |
|
||||
| `TRANSCRIPT_CHANNEL_ID` | No | Where transcripts are posted on close. |
|
||||
| `LOGGING_CHANNEL_ID` | No | Ticket lifecycle logs. |
|
||||
| `DEBUGGING_CHANNEL_ID` | No | Optional error/debug forwarding. |
|
||||
| `BACKUP_EXPORT_CHANNEL_ID` | No | Target for `/backup` and `/export`. |
|
||||
| `ACCOUNT_INFO_CHANNEL_ID` | No | Account info flows. |
|
||||
|
||||
### Escalation categories
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `EMAIL_ESCALATED_CATEGORY_ID` | Legacy fallback; alias `ESCALATED_CATEGORY_ID`. |
|
||||
| `DISCORD_ESCALATED_CATEGORY_ID` | Discord fallback tier-2–style bucket. |
|
||||
| `DISCORD_ESCALATED2_CHANNEL_ID` | Tier **2** placement for Discord tickets (or + fallback category). |
|
||||
| `EMAIL_ESCALATED2_CHANNEL_ID` | Tier **2** for email tickets (env name says CHANNEL for legacy reasons). |
|
||||
| `DISCORD_ESCALATED3_CHANNEL_ID` | Tier **3** Discord. |
|
||||
| `EMAIL_ESCALATED3_CHANNEL_ID` | Tier **3** email. |
|
||||
|
||||
Slash `/escalate` and buttons require the appropriate tier IDs for **non-thread** channels (threads skip category moves).
|
||||
|
||||
### Staff notifications, claimer display, admin
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `STAFF_NOTIFICATION_CATEGORY_ID` | Category where **`/notification add`** creates per-staffer notification channels. |
|
||||
| `STAFF_EMOJIS` | Comma-separated `discordUserId:emoji` pairs; used in **channel name** when a ticket is claimed. |
|
||||
| `CLAIMER_EMOJI_FALLBACK` | Emoji if the claimer has no `STAFF_EMOJIS` entry. |
|
||||
| `ADMIN_ID` | Discord user ID allowed to use **`/staffnotification`** (override cooldown for another member). |
|
||||
| `UNCLAIMED_REMINDER_THRESHOLDS` | Comma-separated **hours** (e.g. `1,2,4`); drives unclaimed ticket alerts into notification channels. |
|
||||
|
||||
### Logging & observability
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GMAIL_LOG_CHANNEL_ID` | Channel for Gmail poll activity logs. |
|
||||
| `AUTOMATION_LOG_CHANNEL_ID` | Channel for auto-close/auto-unclaim/reminder logs. |
|
||||
| `RENAME_LOG_CHANNEL_ID` | Channel for channel rename queue logs. |
|
||||
| `SECURITY_LOG_CHANNEL_ID` | Channel for security/audit logs. |
|
||||
| `SYSTEM_LOG_CHANNEL_ID` | Channel for bot lifecycle logs (startup, shutdown, DB events). |
|
||||
|
||||
### Pattern detection & surge/chat alerts
|
||||
|
||||
Core behaviour is configured via `.env.example`; high level:
|
||||
|
||||
- **Pattern detection** (`patternStore.js`, `patternChecker.js`):
|
||||
- `USER_PATTERNS_CHANNEL_ID`, `GAME_PATTERNS_CHANNEL_ID`, `TAG_PATTERNS_CHANNEL_ID`, `ESCALATION_PATTERNS_CHANNEL_ID`, `STAFF_PATTERNS_CHANNEL_ID`, `COMBINED_PATTERNS_CHANNEL_ID` select where pattern embeds are posted.
|
||||
- Threshold envs like `PATTERN_USER_TICKET_THRESHOLD`, `PATTERN_GAME_TICKET_THRESHOLD`, `PATTERN_UNCLAIMED_HOURS`, `PATTERN_ESCALATION_THRESHOLD`, `PATTERN_RAPID_CLOSE_SECONDS` tune when alerts fire.
|
||||
- Windows (`today`, `week`, `month`) reset automatically via scheduled timers in `patternStore.scheduleResets()`.
|
||||
- **Surge detection** (`surgeChecker.js`):
|
||||
- `ALL_STAFF_CHANNEL_ID` is the primary surge-alert channel; `SURGE_ROLE_ID` is pinged when set.
|
||||
- `SURGE_TICKET_COUNT` / `SURGE_TICKET_WINDOW_MINUTES`, `SURGE_GAME_TICKET_COUNT` / `SURGE_GAME_TICKET_WINDOW_MINUTES`, `SURGE_STALE_*`, `SURGE_NEEDS_RESPONSE_*`, `SURGE_UNCLAIMED_*`, `SURGE_TIER3_UNCLAIMED_MINUTES`, `SURGE_COOLDOWN_MINUTES` control volume/backlog alerts.
|
||||
- `SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD`, `SURGE_NO_STAFF_COOLDOWN_MINUTES`, `STAFF_IDS`, and `STAFF_DND_COUNTS_AS_AVAILABLE` drive “no staff available” alerts (presence-based with message activity fallback).
|
||||
- **Chat monitoring** (`chatAlertChecker.js`):
|
||||
- `CHAT_ALERT_CHANNEL_IDS` lists channels to monitor.
|
||||
- `CHAT_ALERT_MESSAGE_COUNT`, `CHAT_ALERT_HOURS_WITHOUT_RESPONSE`, `CHAT_ALERT_COOLDOWN_MINUTES` configure when to send chat-attention alerts to `ALL_STAFF_CHAT_ALERT_CHANNEL_ID`.
|
||||
|
||||
### Google / Gmail
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Yes | OAuth2 client ID. |
|
||||
| `GOOGLE_CLIENT_SECRET` | Yes | OAuth2 secret. |
|
||||
| `REFRESH_TOKEN` | Yes | Long-lived refresh for the inbox account. |
|
||||
| `MY_EMAIL` | Yes | Canonical support address (lowercased in config). |
|
||||
|
||||
### MongoDB
|
||||
|
||||
| Variable | Required |
|
||||
|----------|----------|
|
||||
| `MONGODB_URI` | Yes |
|
||||
|
||||
Test: `npm run test-mongodb` (optionally with `ENV_FILE` / `.env.test` as above).
|
||||
|
||||
### Server & optional HTTP API
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DISCORD_ONLY_PORT` | `5000` | Express listen port (`CONFIG.PORT`). |
|
||||
| `HEALTHCHECK_HOST` | *(all interfaces)* | e.g. `127.0.0.1` for local-only bind. |
|
||||
|
||||
Additional variables for mounting **`/api`** (API key, CORS, etc.) are listed in `.env.example` if you use that integration.
|
||||
|
||||
### Messaging & branding
|
||||
|
||||
See `.env.example` for defaults: `ESCALATION_MESSAGE` (`{support_name}`), `TICKET_WELCOME_MESSAGE`, `TICKET_CLAIMED_MESSAGE` / `TICKET_UNCLAIMED_MESSAGE` (`{staff_mention}`, `{staff_name}`), `DISCORD_CLOSE_MESSAGE`, `DISCORD_TRANSCRIPT_MESSAGE` (`{channel_name}`, `{email}`, `{date_opened}`, `{date_closed}`), `EMAIL_SIGNATURE` (`\n` → `<br>`), embed color hex vars, button labels/emojis, `SUPPORT_NAME`, `LOGO_URL`.
|
||||
|
||||
### Automation & limits
|
||||
|
||||
- **Auto-close:** `AUTO_CLOSE_ENABLED`, `AUTO_CLOSE_AFTER_HOURS`, `AUTO_CLOSE_MESSAGE`.
|
||||
- **Reminders:** `REMINDER_ENABLED`, `REMINDER_AFTER_HOURS`, `REMINDER_MESSAGE` (`{ping}`, `{hours}`).
|
||||
- **Limits:** `GLOBAL_TICKET_LIMIT`, `TICKET_LIMIT_PER_CATEGORY`, `RATE_LIMIT_TICKETS_PER_USER`, `RATE_LIMIT_WINDOW_MINUTES`.
|
||||
- **Claim:** `ALLOW_CLAIM_OVERWRITE`, `AUTO_UNCLAIM_*`, `CLAIM_TIMEOUT_ENABLED`, `CLAIM_TIMEOUT_HOURS`.
|
||||
- **Priority:** `PRIORITY_ENABLED`, `DEFAULT_PRIORITY`, `PRIORITY_*_EMOJI`.
|
||||
|
||||
### Game list
|
||||
|
||||
`GAME_LIST=comma,separated,names` — used for detection/normalization in email handling (plus aliases in `config.js`).
|
||||
|
||||
### Thread-style tickets (legacy)
|
||||
|
||||
`USE_THREADS`, `THREAD_PARENT_CHANNEL` (see `.env.example`) — optional legacy paths; primary behavior is also governed by **`GuildSettings.emailRouting`** (`/email-routing`: `thread` | `category`).
|
||||
|
||||
---
|
||||
|
||||
## Staff notification channels & reply alerts
|
||||
|
||||
When `STAFF_NOTIFICATION_CATEGORY_ID` is set:
|
||||
|
||||
1. **`/notification add`** (with a target member) creates a channel under that category and saves `userId` → `channelId` + default **cooldown** in **`StaffNotification`**.
|
||||
2. **`/notification set hours:`** (1–6) updates the cooldown between **reply alerts** for that user’s claimed tickets (same ticket keys off `gmailThreadId`).
|
||||
3. **`/staffnotification`** (admin, `ADMIN_ID`) sets cooldown for **another** staff member.
|
||||
4. On **messageCreate**, if the ticket has a `claimerId` and the author is **not** detected as having `ROLE_ID_TO_PING`, **`notifyStaffOfReply`** may post in the claimer’s notification channel (respecting cooldown).
|
||||
5. **Every 30 minutes**, **`notifyAllStaffUnclaimed`** evaluates open tickets with `claimedBy: null` against `UNCLAIMED_REMINDER_THRESHOLDS` and posts to all configured notification channels (tracks sent thresholds on the ticket in `unclaimedReminderssent`).
|
||||
|
||||
**`/notifydm`** toggles **`StaffSettings.notifyDm`** for the invoking user; when enabled, claimers can also receive a **DM** on customer reply (in addition to any notification channel).
|
||||
|
||||
---
|
||||
|
||||
## Broccolini settings page
|
||||
|
||||
The repo includes an optional **Broccolini settings** web UI under `settings-site/` for configuring the bot without editing `.env` directly.
|
||||
|
||||
- Runs as a small Express app (`settings-site/server.js`) on `SETTINGS_PORT` and talks to the bot’s internal API on `INTERNAL_API_PORT` using `INTERNAL_API_SECRET`.
|
||||
- Serves a password-protected dashboard (`SETTINGS_ADMIN_PASSWORD`) where you can adjust Discord channels, categories, Gmail credentials, ticket behavior, surge alerts, pattern thresholds, appearance, staff options, and advanced settings.
|
||||
- Changes are sent to the bot’s internal `/internal/config` endpoints and can be saved as pending, applied immediately, or saved and paired with an immediate or scheduled restart.
|
||||
|
||||
To use it, run `node settings-site/server.js` alongside the bot (or via Docker), set the `SETTINGS_*` and `INTERNAL_API_*` variables as in `.env.example`, and put it behind HTTPS with your preferred reverse proxy.
|
||||
|
||||
---
|
||||
|
||||
## Running the bot (test and Docker)
|
||||
|
||||
```bash
|
||||
npm start
|
||||
# or
|
||||
node broccolini-discord.js
|
||||
```
|
||||
|
||||
**Test / alternate env file:** see [Installation](#installation) for `ENV_FILE` on Windows vs Unix.
|
||||
|
||||
```bash
|
||||
npm run test-mongodb
|
||||
```
|
||||
|
||||
**Docker** (see [`Dockerfile`](Dockerfile)):
|
||||
|
||||
```bash
|
||||
docker build -t broccolini-bot .
|
||||
docker run --env-file .env -p 5000:5000 broccolini-bot
|
||||
```
|
||||
|
||||
Ensure `MONGODB_URI` and Discord token are available inside the container. A sample [`docker-compose.yml`](docker-compose.yml) exists—adjust **ports** and **env_file** for your host (do not copy production-specific bind addresses into new deployments without review).
|
||||
|
||||
---
|
||||
|
||||
## Discord commands
|
||||
|
||||
Most commands require **staff** (`ROLE_ID_TO_PING` or `ADDITIONAL_STAFF_ROLES`). **`/help`** is available more broadly per registration.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **`/setup`** | Guild setup wizard (panel, role, category, transcript channel, etc.). |
|
||||
| **`/panel`** | Post a ticket **Open** button in a channel (optional `type`: thread / category / both; custom title/description). |
|
||||
| **`/email-routing`** | Choose whether **new email** tickets create **threads** vs **category channels** (`GuildSettings` in DB). |
|
||||
| **`/escalate`** | **Required:** `level` (Tier 2 or Tier 3), `action` (`unclaim` clears `claimedBy` + `claimerId` after escalation, `keep` preserves claim). |
|
||||
| **`/deescalate`** | Step down one tier (tier 3 → 2 → normal). |
|
||||
| **`/notifydm`** | `setting`: `on` / `off` — DM when a **non-staff** user replies in a ticket you claimed. |
|
||||
| **`/notification`** | Subcommands: `set` (cooldown hours), `add` (create notification channel for a member). |
|
||||
| **`/staffnotification`** | Admin only (`ADMIN_ID`); override another member’s notification cooldown. |
|
||||
| **`/add`**, **`/remove`** | Add/remove user overwrites on the current ticket channel. |
|
||||
| **`/transfer`** | Set `claimedBy` to another staff member (must have staff role). |
|
||||
| **`/move`** | Move channel to another **category** (direct `setParent`). |
|
||||
| **`/force-close`** | Close without button confirmation (still archives transcript best-effort). |
|
||||
| **`/topic`** | Set Discord channel topic. |
|
||||
| **`/priority`** | `low` / `normal` / `medium` / `high`. |
|
||||
| **`/tag`** | Set ticket tag category from dropdown (choices from `TICKET_TAGS` in `config.js`). |
|
||||
| **`/response`** | Subcommands: `send`, `create`, `edit`, `delete`, `list` (saved responses). |
|
||||
| **`/accountinfo`** | Subcommands: `email`, `discord`. |
|
||||
| **`/search`** | Search tickets by email, subject, or number. |
|
||||
| **`/stats`** | Bot analytics snapshot. |
|
||||
| **`/backup`**, **`/export`** | Post exports to `BACKUP_EXPORT_CHANNEL_ID`. |
|
||||
| **`/help`** | In-bot command summary embed. |
|
||||
|
||||
**Context menus**
|
||||
|
||||
- **Create Ticket From Message** — opens a ticket prefilled from a message.
|
||||
- **View User Tickets** — lists recent tickets for a user (by sender tag match).
|
||||
|
||||
---
|
||||
|
||||
## Ticket UI (buttons & modals)
|
||||
|
||||
- **Open ticket** (panel): modal fields are **account email**, **game**, **description**.
|
||||
- In ticket channels: **Close**, **Claim/Unclaim**, **Escalate** (tier choice), **De-escalate** as built in [`utils/ticketComponents.js`](utils/ticketComponents.js) / [`handlers/buttons.js`](handlers/buttons.js).
|
||||
- **Email routing** and **tag delete** confirmations use additional button custom IDs.
|
||||
|
||||
---
|
||||
|
||||
## Tag & response system
|
||||
|
||||
### `/tag`
|
||||
|
||||
Sets `ticketTag` from the fixed list in `config.js` (`TICKET_TAGS`). Channel naming may incorporate tag/priority emojis via ticket naming logic.
|
||||
|
||||
### `/response`
|
||||
|
||||
Templates support variables such as `{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{staff.mention}`, `{server.name}`, `{date}`, `{time}` (see [`utils.js`](utils.js) / handler docs).
|
||||
|
||||
---
|
||||
|
||||
## Panel system
|
||||
|
||||
1. Run **`/panel`** targeting a channel (and optional style: thread-only, category-only, or both buttons).
|
||||
2. User clicks **Open ticket** → modal → bot creates thread or channel per configuration.
|
||||
3. Welcome embeds + action row are posted; `Ticket` stores `discordThreadId`, `ticketNumber`, etc.
|
||||
|
||||
---
|
||||
|
||||
## Channel renames & moves (rate limits)
|
||||
|
||||
Discord allows **two renames per 10 minutes** per channel. The bot serializes renames/moves through [`services/channelQueue.js`](services/channelQueue.js) (`p-queue`). If rename is blocked, staff see a message with a **relative time** to retry.
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
broccolini-bot/
|
||||
├── broccolini-discord.js # Entry: Discord client, Express, Gmail poll interval, jobs
|
||||
├── config.js # Env → CONFIG (game lists, TICKET_TAGS, STAFF_EMOJIS map, …)
|
||||
├── db-connection.js # Mongo connect + require models
|
||||
├── models.js # Mongoose schemas (Ticket, Tag, StaffSettings, StaffNotification, …)
|
||||
├── utils.js # Email/game helpers, template variables
|
||||
├── utils/ticketComponents.js # Action row builders
|
||||
├── gmail-poll.js # Ingest Gmail → Discord ticket creation
|
||||
├── get-refresh-token.js # One-shot OAuth refresh token helper
|
||||
├── commands/register.js # Slash + context menu registration (discord.js v14)
|
||||
├── handlers/
|
||||
│ ├── buttons.js # Claim/close/modals/escalate buttons, ticket create modal
|
||||
│ ├── commands.js # Slash handlers, runEscalation/runDeescalation
|
||||
│ ├── messages.js # Staff ↔ Gmail relay; notifydm; notification alerts
|
||||
│ ├── accountinfo.js
|
||||
│ ├── analytics.js
|
||||
│ └── setup.js
|
||||
├── services/
|
||||
│ ├── gmail.js
|
||||
│ ├── tickets.js # Auto-close, reminders, auto-unclaim, naming helpers
|
||||
│ ├── channelQueue.js # enqueueRename / enqueueMove
|
||||
│ ├── staffChannel.js # Legacy mirror helpers (unused in current claim flow)
|
||||
│ ├── staffNotifications.js # Reply alerts + unclaimed reminders
|
||||
│ ├── staffSettings.js # notifydm prefs
|
||||
│ ├── guildSettings.js
|
||||
│ └── debugLog.js
|
||||
├── routes/ # Optional Express `/api` routes
|
||||
├── api/ # Bot client accessor for HTTP layer
|
||||
├── scripts/ # Maintenance / one-off utilities
|
||||
├── docs/ # Deeper guides (setup, security, MongoDB, API notes)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── package.json
|
||||
└── .env.example / .env.test.example
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database collections
|
||||
|
||||
| Model / collection | Role |
|
||||
|--------------------|------|
|
||||
| **Ticket** | Gmail thread id, Discord channel/thread id, status, priority, claim (`claimedBy` display label, `claimerId`), legacy `staffChannelId`, escalation tier, `welcomeMessageId`, `ticketTag`, `unclaimedReminderssent`, etc. |
|
||||
| **TicketCounter** | Per-sender local counters (legacy paths). |
|
||||
| **Transcript** | Links closed tickets to transcript message IDs. |
|
||||
| **Tag** | Saved response name + content. |
|
||||
| **GuildSettings** | e.g. `emailRouting`: `thread` \| `category`. |
|
||||
| **StaffSettings** | Per-user `notifyDm` (+ `guildId`, `updatedAt`). |
|
||||
| **StaffNotification** | Per-user `channelId`, `cooldownHours` for reply/unclaimed alerts. |
|
||||
| **CloseRequest** | Pending close workflow if used. |
|
||||
| **User**, **Host**, **DashboardMetrics**, **ErrorLog** | Shared / website-era schemas in the same `models.js` file. |
|
||||
|
||||
---
|
||||
|
||||
## HTTP: healthcheck & optional API
|
||||
|
||||
- **`GET /`** → plain text **`Active`** (intended for load balancers / Docker `HEALTHCHECK`).
|
||||
- **`/api/*`** is registered **only after** the bot is `ready` and the optional HTTP API is enabled via env (see `.env.example`). JSON body parsing is enabled; auth uses a Bearer token from configuration. Route definitions live under `routes/` in this repo.
|
||||
|
||||
---
|
||||
|
||||
## Gmail OAuth refresh token
|
||||
|
||||
```bash
|
||||
node get-refresh-token.js
|
||||
```
|
||||
|
||||
Requires `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env`, and redirect URI **`http://localhost:3000/oauth2callback`** registered on the Google OAuth client. Paste the printed refresh token into `.env` as `REFRESH_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
## Documentation in `docs/`
|
||||
|
||||
Index: **[docs/README.md](docs/README.md)**. Highlights:
|
||||
|
||||
| Doc | Topic |
|
||||
|-----|--------|
|
||||
| [ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md) | Secrets, test env, agent rules |
|
||||
| [MONGODB_SETUP.md](docs/setup/MONGODB_SETUP.md) | Database |
|
||||
| [QUICKSTART.md](docs/setup/QUICKSTART.md) | First-time orientation |
|
||||
| [PROJECT_STRUCTURE.md](docs/setup/PROJECT_STRUCTURE.md) | Layout (may overlap this README) |
|
||||
| [1PASSWORD.md](docs/setup/1PASSWORD.md) | 1Password CLI for `npm run start:1p` |
|
||||
|
||||
---
|
||||
|
||||
## Development & CI
|
||||
|
||||
This repo includes [`.gitlab-ci.yml`](.gitlab-ci.yml) with GitLab **SAST** and **secret detection** templates. Adjust or extend stages in GitLab as needed for your fork.
|
||||
|
||||
---
|
||||
All config is environment variables loaded by `config.js` into `CONFIG`. The full list — with descriptions and defaults — lives in [`.env.example`](.env.example). Highlights:
|
||||
|
||||
| Variable | Notes |
|
||||
|----------|-------|
|
||||
| `DISCORD_TOKEN` / `DISCORD_BOT_TOKEN` | Bot token. First non-empty after trim wins. |
|
||||
| `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID` | Required for slash command registration. |
|
||||
| `TICKET_CATEGORY_ID` | Default category for email tickets. Validated at startup. |
|
||||
| `DISCORD_TICKET_CATEGORY_ID` | Category for Discord panel/context tickets (falls back to `TICKET_CATEGORY_ID`). |
|
||||
| `ROLE_ID_TO_PING` | Support role pinged on new tickets. |
|
||||
| `MONGODB_URI` | Mongo connection string. |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `REFRESH_TOKEN` / `MY_EMAIL` | Gmail OAuth + canonical inbox address. |
|
||||
| `RENAMER_BOT` | Optional secondary token used for channel renames. |
|
||||
| `INTERNAL_API_SECRET` / `INTERNAL_API_PORT` | Enable the internal config API used by the settings UI. |
|
||||
|
||||
## Slash commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/escalate`, `/deescalate` | Move ticket between tier 2/3 categories. |
|
||||
| `/add`, `/remove` | Add/remove user from current ticket channel. |
|
||||
| `/transfer` | Hand the claim to another staff member. |
|
||||
| `/move` | Reparent the channel to another category. |
|
||||
| `/force-close`, `/cancel-close`, `/closetimer` | Force-close flow with cancellable countdown. |
|
||||
| `/topic` | Set channel topic. |
|
||||
| `/response` | Saved reply templates (`send`, `create`, `edit`, `delete`, `list`). |
|
||||
| `/panel` | Post an "Open ticket" panel button (thread / category / both). |
|
||||
| `/notifydm` | Toggle DM alerts when a customer replies in your claimed ticket. |
|
||||
| `/signature` | Personal email signature (valediction, display name, tagline). |
|
||||
| `/staffthread` | Toggle / configure staff-only threads on tickets. |
|
||||
| `/pinmessages` | Auto-pin welcome / escalation messages. |
|
||||
| `/gmailpoll` | Set the Gmail poll interval at runtime. |
|
||||
| `/help` | In-bot summary. |
|
||||
|
||||
Plus context menus: **Create Ticket From Message**, **View User Tickets**.
|
||||
|
||||
## Settings UI (optional)
|
||||
|
||||
`settings-site/` is a separate Express app that talks to the bot's internal config API over the `broccoli-net` Docker network using `INTERNAL_API_SECRET`. It is **not** part of this bot's process. See [`settings-site/CLAUDE.md`](settings-site/CLAUDE.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Checks |
|
||||
|---------|--------|
|
||||
| **Commands missing** | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; **restart** bot; Discord can take time to sync. |
|
||||
| **Gmail not ingesting** | `REFRESH_TOKEN`, API enablement, inbox auth; regenerate token if revoked. |
|
||||
| **MongoDB errors** | `MONGODB_URI`, `npm run test-mongodb`. |
|
||||
| **Channels not creating** | Bot **Manage Channels** in ticket categories; category not full (50) unless overflow set. |
|
||||
| **Modal / button no response** | Intents + permissions; bot online; check `DEBUGGING_CHANNEL_ID` / console. |
|
||||
| **Renames “too quickly”** | Discord rename cooldown; wait for channel queue / timestamp in bot message. |
|
||||
| **Test script env on Windows** | `npm run start:test` sets `ENV_FILE` Unix-style; use PowerShell `ENV_FILE` + `node` if the script fails. |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
| Technology | Link |
|
||||
|------------|------|
|
||||
| discord.js v14 | [discord.js guide](https://discordjs.guide/) |
|
||||
| Google APIs (Gmail) | [googleapis Node](https://github.com/googleapis/google-api-nodejs-client) |
|
||||
| Mongoose | [mongoosejs.com](https://mongoosejs.com/) |
|
||||
| Express | [expressjs.com](https://expressjs.com/) |
|
||||
|
||||
---
|
||||
| Symptom | Check |
|
||||
|---------|-------|
|
||||
| Slash commands missing | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; restart; Discord can take a minute to sync. |
|
||||
| Gmail not ingesting | `REFRESH_TOKEN` valid? Auth failure halts polling — re-auth and restart. |
|
||||
| Mongo errors at startup | `MONGODB_URI` reachable? `npm run test-mongodb` to confirm. |
|
||||
| Channel rename "too quickly" | Discord limit is 2 renames/10 min per channel — the queue serializes; wait it out. |
|
||||
| Modal/button no response | Bot online + intents enabled; check `DEBUGGING_CHANNEL_ID` / container logs. |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* bOSScord API: reference to the Discord bot client.
|
||||
* Set in broccolini-discord.js when client fires "ready"; read by bosscord routes.
|
||||
*/
|
||||
let botClient = null;
|
||||
|
||||
function setBot(client) {
|
||||
botClient = client;
|
||||
}
|
||||
|
||||
function getBot() {
|
||||
return botClient;
|
||||
}
|
||||
|
||||
module.exports = { setBot, getBot };
|
||||
@@ -2,7 +2,7 @@
|
||||
* Entry point – initializes the Discord bot, wires event handlers,
|
||||
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck.
|
||||
*/
|
||||
const { Client, GatewayIntentBits, Partials } = require('discord.js');
|
||||
const { Client, GatewayIntentBits, Partials, MessageFlags } = require('discord.js');
|
||||
const express = require('express');
|
||||
const { connectMongoDB, closeMongoDB } = require('./db-connection');
|
||||
const { CONFIG } = require('./config');
|
||||
@@ -11,22 +11,15 @@ const { mongoose } = require('./db-connection');
|
||||
// Handlers
|
||||
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
||||
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
||||
const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup');
|
||||
const { requireStaffRole } = require('./handlers/commands/helpers');
|
||||
const { handleDiscordReply } = require('./handlers/messages');
|
||||
|
||||
// Services & jobs
|
||||
const { sendTicketClosedEmail } = require('./services/gmail');
|
||||
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
|
||||
const { registerCommands } = require('./commands/register');
|
||||
// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup.
|
||||
const { setBot } = require('./api/botClient');
|
||||
const { poll } = require('./gmail-poll');
|
||||
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog');
|
||||
|
||||
// Re-export utilities for any external consumers
|
||||
const { sendGmailReply } = require('./services/gmail');
|
||||
const { getNextTicketNumber } = require('./services/tickets');
|
||||
const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils');
|
||||
const { setClient: setDebugClient, logError } = require('./services/debugLog');
|
||||
|
||||
let gmailPollInterval = null;
|
||||
// Track all background setInterval handles so shutdown can clear them.
|
||||
@@ -94,7 +87,7 @@ const client = new Client({
|
||||
|
||||
// --- EVENT: interactionCreate ---
|
||||
async function safeReplyError(interaction) {
|
||||
const payload = { content: 'Something went wrong.', ephemeral: true };
|
||||
const payload = { content: 'Something went wrong.', flags: MessageFlags.Ephemeral };
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
await interaction.followUp(payload).catch(() => {});
|
||||
} else {
|
||||
@@ -113,31 +106,14 @@ async function runHandler(name, interaction, fn) {
|
||||
}
|
||||
|
||||
client.on('interactionCreate', async interaction => {
|
||||
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
|
||||
try {
|
||||
const handled = await handleSetupButton(interaction);
|
||||
if (handled) return;
|
||||
} catch (err) {
|
||||
console.error('Setup button error:', err);
|
||||
logError('handleSetupButton', err, null, client).catch(() => {});
|
||||
await interaction.reply({
|
||||
content: `Setup error: ${err.message}. Try \`/setup\` again.`,
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
return runHandler('handleButton', interaction, () => handleButton(interaction));
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) {
|
||||
const handled = await runHandler('handleSetupModal', interaction, () => handleSetupModal(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
|
||||
// Staff-only: /signature shows this modal, which is gated; double-gate the
|
||||
// submit path in case an attacker crafts the submission directly.
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
// Handle signature modal submit
|
||||
try {
|
||||
const valediction = interaction.fields.getTextInputValue('valediction');
|
||||
@@ -160,13 +136,13 @@ client.on('interactionCreate', async interaction => {
|
||||
|
||||
await interaction.reply({
|
||||
content: 'Signature settings saved successfully!',
|
||||
ephemeral: true
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Signature modal submit error:', err);
|
||||
await interaction.reply({
|
||||
content: 'Failed to save signature settings.',
|
||||
ephemeral: true
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -176,11 +152,6 @@ client.on('interactionCreate', async interaction => {
|
||||
return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction));
|
||||
}
|
||||
|
||||
if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) {
|
||||
const handled = await runHandler('handleSetupSelect', interaction, () => handleSetupSelect(interaction));
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
|
||||
}
|
||||
@@ -198,6 +169,14 @@ client.on('messageCreate', async msg => {
|
||||
await handleDiscordReply(msg);
|
||||
});
|
||||
|
||||
// HTTP server handles + readiness flag. Assigned inside the ready callback
|
||||
// (httpServer, appReady) and the INTERNAL_API_SECRET branch below
|
||||
// (internalServer); declared here so they're visible to the ready callback,
|
||||
// the express middleware below, and the shutdown handler at the bottom.
|
||||
let httpServer = null;
|
||||
let internalServer = null;
|
||||
let appReady = false;
|
||||
|
||||
client.once('ready', async () => {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
|
||||
@@ -205,7 +184,6 @@ client.once('ready', async () => {
|
||||
}
|
||||
await connectMongoDB(process.env.MONGODB_URI);
|
||||
setDebugClient(client);
|
||||
setBot(client);
|
||||
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
|
||||
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
|
||||
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
||||
@@ -255,18 +233,6 @@ client.once('ready', async () => {
|
||||
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
|
||||
|
||||
console.log('✓ Discord bot ready. Tag:', client.user.tag);
|
||||
|
||||
logSystem('Bot online', [
|
||||
{ name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' },
|
||||
{ name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` },
|
||||
{ name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' },
|
||||
{ name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' },
|
||||
{ name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
|
||||
{ name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
|
||||
{ name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' },
|
||||
{ name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' },
|
||||
{ name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' }
|
||||
]).catch(() => {});
|
||||
});
|
||||
|
||||
client.login(CONFIG.DISCORD_TOKEN);
|
||||
@@ -274,7 +240,7 @@ client.login(CONFIG.DISCORD_TOKEN);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Reject API traffic with 503 until ready event has fired and routes are mounted.
|
||||
let appReady = false;
|
||||
// (appReady is declared at module top so the ready callback can flip it.)
|
||||
app.use((req, res, next) => {
|
||||
if (!appReady && req.path.startsWith('/api')) {
|
||||
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
|
||||
@@ -289,8 +255,6 @@ const internalApi = require('./routes/internalApi');
|
||||
const internalApp = express();
|
||||
internalApp.use('/internal', internalApi);
|
||||
|
||||
let httpServer = null;
|
||||
let internalServer = null;
|
||||
if (CONFIG.INTERNAL_API_SECRET) {
|
||||
// Must bind all-interfaces inside the bot container: the settings-site is a
|
||||
// separate container on broccoli-net and reaches this API over the docker
|
||||
@@ -310,10 +274,7 @@ let shuttingDown = false;
|
||||
async function handleShutdown(signal) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await Promise.race([
|
||||
logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]),
|
||||
new Promise(r => setTimeout(r, 2000))
|
||||
]);
|
||||
console.log(`Bot shutting down (${signal})`);
|
||||
for (const handle of activeIntervals) {
|
||||
try { clearInterval(handle); } catch (_) {}
|
||||
}
|
||||
@@ -339,13 +300,5 @@ module.exports = {
|
||||
client,
|
||||
setGmailPollInterval,
|
||||
clearGmailPollInterval,
|
||||
trackTimeout,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
getNextTicketNumber,
|
||||
getCleanBody,
|
||||
detectGame,
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
htmlToTextWithBlocks
|
||||
trackTimeout
|
||||
};
|
||||
|
||||
@@ -205,13 +205,6 @@ async function registerCommands() {
|
||||
InteractionContextType.PrivateChannel
|
||||
]),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('setup')
|
||||
.setDescription('Run the panel setup wizard (name, support role, category, transcript channel, panel channel)')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('panel')
|
||||
.setDescription('Create a ticket panel for users to open Discord tickets')
|
||||
@@ -253,13 +246,6 @@ async function registerCommands() {
|
||||
.setRequired(false)
|
||||
),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('email-routing')
|
||||
.setDescription('Switch where new email tickets are created: threads or category channels')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
|
||||
|
||||
new SlashCommandBuilder()
|
||||
.setName('notifydm')
|
||||
.setDescription('Toggle DM notifications when your ticket receives a customer reply.')
|
||||
@@ -371,8 +357,6 @@ async function registerCommands() {
|
||||
.setDescription('Poll interval')
|
||||
.setRequired(true)
|
||||
.addChoices(
|
||||
{ name: '5s', value: '5' },
|
||||
{ name: '10s', value: '10' },
|
||||
{ name: '30s', value: '30' },
|
||||
{ name: '45s', value: '45' },
|
||||
{ name: '1m', value: '60' },
|
||||
|
||||
82
config.js
82
config.js
@@ -1,25 +1,9 @@
|
||||
/**
|
||||
* Broccolini Bot configuration and game lists.
|
||||
* Load dotenv so env is available when this module is required first.
|
||||
* dotenv-expand resolves ${NGROK_URL} etc. in .env.
|
||||
*
|
||||
* Never commit .env; agents must not modify .env without explicit user confirmation.
|
||||
*/
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const dotenvExpand = require('dotenv-expand');
|
||||
|
||||
const parsed = dotenv.config({ debug: process.env.NODE_ENV === 'development' });
|
||||
dotenvExpand.expand(parsed);
|
||||
// Also load repo-root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env)
|
||||
const rootEnv = path.resolve(process.cwd(), '..', '.env');
|
||||
const rootParsed = dotenv.config({ path: rootEnv });
|
||||
if (!rootParsed.error && rootParsed.parsed) {
|
||||
for (const [k, v] of Object.entries(rootParsed.parsed)) {
|
||||
if (v != null && String(v).trim() !== '') process.env[k] = v;
|
||||
}
|
||||
dotenvExpand.expand(rootParsed);
|
||||
}
|
||||
require('dotenv').config({ debug: process.env.NODE_ENV === 'development' });
|
||||
|
||||
function toInt(v, fallback) {
|
||||
const n = parseInt(v, 10);
|
||||
@@ -31,34 +15,23 @@ const CONFIG = {
|
||||
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
|
||||
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
|
||||
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
|
||||
TICKET_T2_CATEGORY_NAME: process.env.TICKET_T2_CATEGORY_NAME || 'Tier 2 Escalated Tickets',
|
||||
TICKET_T3_CATEGORY_NAME: process.env.TICKET_T3_CATEGORY_NAME || 'Tier 3 Escalated Tickets',
|
||||
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
|
||||
DISCORD_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
|
||||
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
|
||||
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
|
||||
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
|
||||
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
|
||||
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
|
||||
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
||||
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
|
||||
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
||||
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
||||
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
|
||||
LOGO_URL: process.env.LOGO_URL,
|
||||
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
|
||||
STAFF_EMOJIS: Object.fromEntries((process.env.STAFF_EMOJIS||'').split(',').map(s=>s.trim()).filter(Boolean).map(p=>{const i=p.indexOf(':');return i===-1?null:[p.slice(0,i).trim(),p.slice(i+1).trim()];}).filter(Boolean)),
|
||||
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
|
||||
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
|
||||
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
|
||||
GAME_LIST: process.env.GAME_LIST || '',
|
||||
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
|
||||
EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null,
|
||||
// Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming).
|
||||
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
|
||||
DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_ESCALATED2_CHANNEL_ID || null,
|
||||
@@ -75,9 +48,7 @@ const CONFIG = {
|
||||
DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.',
|
||||
AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true',
|
||||
AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72),
|
||||
AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.',
|
||||
GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5),
|
||||
TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3),
|
||||
RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0),
|
||||
RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60),
|
||||
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
|
||||
@@ -96,8 +67,6 @@ const CONFIG = {
|
||||
AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
|
||||
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
|
||||
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
|
||||
USE_THREADS: process.env.USE_THREADS === 'true',
|
||||
THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null,
|
||||
BUTTON_LABEL_CLOSE: process.env.BUTTON_LABEL_CLOSE || 'Close Ticket',
|
||||
BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim',
|
||||
BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim',
|
||||
@@ -105,18 +74,13 @@ const CONFIG = {
|
||||
BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌',
|
||||
BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓',
|
||||
EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00),
|
||||
EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000),
|
||||
EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
|
||||
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
|
||||
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
|
||||
ADMIN_ID: process.env.ADMIN_ID || null,
|
||||
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
|
||||
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
|
||||
GMAIL_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null,
|
||||
AUTOMATION_LOG_CHANNEL_ID: process.env.AUTOMATION_LOG_CHANNEL_ID || null,
|
||||
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
|
||||
SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_LOG_CHANNEL_ID || null,
|
||||
SYSTEM_LOG_CHANNEL_ID: process.env.SYSTEM_LOG_CHANNEL_ID || null,
|
||||
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
|
||||
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
|
||||
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
|
||||
@@ -148,42 +112,8 @@ const GAME_ALIASES = {
|
||||
CS2: 'Counter-Strike 2'
|
||||
};
|
||||
|
||||
const GAME_NAME_TO_KEY = {
|
||||
'Project Zomboid': 'project_zomboid',
|
||||
'Satisfactory': 'satisfactory',
|
||||
'Palworld': 'palworld',
|
||||
'Minecraft': 'minecraft',
|
||||
'Valheim': 'valheim',
|
||||
'Enshrouded': 'enshrouded',
|
||||
'7 Days to Die': '7_days_to_die',
|
||||
'Hytale': 'hytale',
|
||||
'ICARUS': 'icarus',
|
||||
'Abiotic Factor': 'abiotic_factor',
|
||||
'ARK: Survival Evolved': 'ark_survival_evolved',
|
||||
'Conan Exiles': 'conan_exiles',
|
||||
'Core Keeper': 'core_keeper',
|
||||
'Counter-Strike 2': 'counter_strike_2',
|
||||
'DayZ': 'dayz',
|
||||
'ECO': 'eco',
|
||||
'Factorio': 'factorio',
|
||||
'FiveM': 'fivem',
|
||||
'The Front': 'the_front',
|
||||
"Garry's Mod": 'garrys_mod',
|
||||
'Necesse': 'necesse',
|
||||
'Rust': 'rust',
|
||||
'Sons of the Forest': 'sons_of_the_forest',
|
||||
'Soulmask': 'soulmask',
|
||||
'Star Rupture': 'star_rupture',
|
||||
'Terraria': 'terraria',
|
||||
'VEIN': 'vein',
|
||||
'Vintage Story': 'vintage_story',
|
||||
'Voyagers of Nera': 'voyagers_of_nera',
|
||||
'V Rising': 'v_rising'
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
CONFIG,
|
||||
GAME_NAMES,
|
||||
GAME_ALIASES,
|
||||
GAME_NAME_TO_KEY
|
||||
GAME_ALIASES
|
||||
};
|
||||
|
||||
@@ -22,23 +22,16 @@ async function connectMongoDB(uri, options = {}) {
|
||||
await mongoose.connect(uri, defaultOptions);
|
||||
console.log('✓ Connected to MongoDB');
|
||||
|
||||
// Handle connection events
|
||||
mongoose.connection.on('error', (err) => {
|
||||
console.error('MongoDB connection error:', err);
|
||||
const { logSystem: ls } = require('./services/debugLog');
|
||||
ls('MongoDB error', [{ name: 'Error', value: err.message }], null, 0xFF0000).catch(() => {});
|
||||
});
|
||||
|
||||
mongoose.connection.on('disconnected', () => {
|
||||
console.warn('MongoDB disconnected. Attempting to reconnect...');
|
||||
const { logSystem: ls } = require('./services/debugLog');
|
||||
ls('MongoDB disconnected', [], null, 0xFFFF00).catch(() => {});
|
||||
});
|
||||
|
||||
mongoose.connection.on('reconnected', () => {
|
||||
console.log('✓ MongoDB reconnected');
|
||||
const { logSystem: ls } = require('./services/debugLog');
|
||||
ls('MongoDB reconnected', []).catch(() => {});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
|
||||
652
gmail-poll.js
652
gmail-poll.js
@@ -1,29 +1,30 @@
|
||||
/**
|
||||
* Gmail polling – fetches unread emails and creates/updates Discord ticket channels.
|
||||
*
|
||||
* `poll()` is the orchestrator: list → locate guild → for each message,
|
||||
* parse → look up existing → branch (append-followup vs create-ticket) → mark read.
|
||||
* Each step delegates to a single-responsibility helper below.
|
||||
*/
|
||||
const {
|
||||
ChannelType,
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder
|
||||
EmbedBuilder,
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose, withRetry } = require('./db-connection');
|
||||
const { CONFIG, GAME_NAME_TO_KEY } = require('./config');
|
||||
const { CONFIG } = require('./config');
|
||||
const {
|
||||
getCleanBody,
|
||||
extractRawEmail,
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
detectGame,
|
||||
enforceEmbedLimit,
|
||||
sanitizeEmbedText
|
||||
} = require('./utils');
|
||||
const { getGmailClient } = require('./services/gmail');
|
||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||
const { getEmailRouting } = require('./services/guildSettings');
|
||||
const { logError, logGmail, logAutomation } = require('./services/debugLog');
|
||||
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
|
||||
const { logError } = require('./services/debugLog');
|
||||
const { enqueueSend } = require('./services/channelQueue');
|
||||
const { getTicketActionRow } = require('./utils/ticketComponents');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
@@ -31,7 +32,6 @@ const Transcript = mongoose.model('Transcript');
|
||||
let isPolling = false;
|
||||
let authErrorNotified = false;
|
||||
let pollSuspended = false;
|
||||
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
|
||||
|
||||
function setPollSuspended(val) {
|
||||
pollSuspended = !!val;
|
||||
@@ -39,6 +39,228 @@ function setPollSuspended(val) {
|
||||
}
|
||||
function isPollSuspended() { return pollSuspended; }
|
||||
|
||||
// ============================================================
|
||||
// Helpers (extracted from the original 309-line poll()).
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Pick the guild for this poll iteration. Honors DISCORD_GUILD_ID when set,
|
||||
* otherwise falls back to the first guild in the cache. Returns null with a
|
||||
* warning if no usable guild is available; caller should bail.
|
||||
*/
|
||||
function locateGuild(client) {
|
||||
if (CONFIG.DISCORD_GUILD_ID) {
|
||||
const g = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||
if (!g) {
|
||||
console.warn('Configured guild not found for DISCORD_GUILD_ID:', CONFIG.DISCORD_GUILD_ID);
|
||||
}
|
||||
return g || null;
|
||||
}
|
||||
const g = client.guilds.cache.first();
|
||||
if (!g) {
|
||||
console.warn('No guilds in cache; skipping poll iteration.');
|
||||
}
|
||||
return g || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Gmail message payload into normalized fields.
|
||||
*
|
||||
* Body cleanup runs twice with different rules:
|
||||
* - firstBody: aggressive — strip quotes if it looks like a reply, strip
|
||||
* mobile footers, collapse newlines. Used as the first message in a new
|
||||
* ticket channel where we want only the user's actual message.
|
||||
* - followupBody: defensive — strip quotes but fall back to raw text if
|
||||
* stripping leaves nothing. Used for follow-up posts on an existing thread.
|
||||
*/
|
||||
function parseGmailMessage(email) {
|
||||
const headers = email.data.payload.headers;
|
||||
const from = headers.find(h => h.name === 'From')?.value || '';
|
||||
const isSelf = from.toLowerCase().includes(CONFIG.MY_EMAIL);
|
||||
const subject = headers.find(h => h.name === 'Subject')?.value || 'New Ticket';
|
||||
const rawBody = getCleanBody(email.data.payload);
|
||||
const senderEmail = extractRawEmail(from).toLowerCase();
|
||||
const senderName = (from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
|
||||
?.replace(/"/g, '')
|
||||
.trim() || 'Unknown';
|
||||
|
||||
const hasReplyHeaderFrom = /(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
|
||||
const looksLikeReply = /\nOn .+wrote:/i.test(rawBody) || hasReplyHeaderFrom;
|
||||
|
||||
let firstBody = rawBody.replace(/\r\n/g, '\n');
|
||||
if (looksLikeReply) firstBody = stripEmailQuotes(firstBody);
|
||||
firstBody = stripMobileFooter(firstBody);
|
||||
firstBody = firstBody.replace(/^\s*\n+/g, '');
|
||||
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
|
||||
firstBody = firstBody
|
||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||
.replace(/<\s*$/gm, '')
|
||||
.trim();
|
||||
|
||||
const rawText = rawBody.replace(/\r\n/g, '\n');
|
||||
let followupBody = stripEmailQuotes(rawText);
|
||||
if (!followupBody.trim()) followupBody = rawText;
|
||||
followupBody = followupBody.replace(/^\s*\n*/, '\n');
|
||||
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
|
||||
followupBody = stripMobileFooter(followupBody);
|
||||
followupBody = followupBody
|
||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||
.replace(/<\s*$/gm, '')
|
||||
.trim();
|
||||
|
||||
return {
|
||||
isSelf,
|
||||
threadId: email.data.threadId,
|
||||
from,
|
||||
subject,
|
||||
rawBody,
|
||||
senderEmail,
|
||||
senderName,
|
||||
firstBody,
|
||||
followupBody
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the parent category and create a fresh ticket channel under it.
|
||||
* Returns { channel, parentCategoryId } on success, or null on failure (caller
|
||||
* should mark the message read and skip — same behavior as the original inline path).
|
||||
*/
|
||||
async function findOrCreateTicketChannel(guild, parsed, number) {
|
||||
const creatorNickname = getSenderLocal(parsed.senderEmail);
|
||||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||||
|
||||
let parentCategoryId;
|
||||
try {
|
||||
parentCategoryId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Channel create error (payload):', {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
rawError: err.rawError
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await guild.channels.create({
|
||||
name: chanName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentCategoryId,
|
||||
// Email tickets have no Discord creator — the customer is reachable
|
||||
// only by email. So the only per-channel allow is the staff role; we
|
||||
// still explicitly deny @everyone in case the category permissions
|
||||
// are ever misconfigured to grant View Channel server-wide.
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
...(CONFIG.ROLE_ID_TO_PING ? [{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}] : [])
|
||||
]
|
||||
});
|
||||
return { channel, parentCategoryId };
|
||||
} catch (createErr) {
|
||||
console.error('Channel create error (email ticket):', createErr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post links + attachments for prior transcripts of a reopened thread.
|
||||
* Best-effort: any failure is logged and swallowed so the new ticket flow
|
||||
* continues unaffected.
|
||||
*/
|
||||
async function linkPreviousTranscripts(ticketChan, threadId, client) {
|
||||
try {
|
||||
const transcriptRows = await Transcript.find({ gmailThreadId: threadId })
|
||||
.sort({ createdAt: 1 })
|
||||
.select('transcriptMessageId')
|
||||
.lean();
|
||||
|
||||
if (transcriptRows.length === 0) return;
|
||||
|
||||
const transcriptChan = await client.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||
.catch(() => null);
|
||||
if (!transcriptChan) return;
|
||||
|
||||
await enqueueSend(
|
||||
ticketChan,
|
||||
`This email thread has ${transcriptRows.length} previous transcript(s):`
|
||||
);
|
||||
|
||||
for (const row of transcriptRows) {
|
||||
const transcriptMsg = await transcriptChan.messages
|
||||
.fetch(row.transcriptMessageId)
|
||||
.catch(() => null);
|
||||
if (!transcriptMsg) continue;
|
||||
|
||||
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
|
||||
|
||||
const originalAttachment = transcriptMsg.attachments.first();
|
||||
if (originalAttachment) {
|
||||
await enqueueSend(ticketChan, {
|
||||
content: 'Transcript file:',
|
||||
files: [originalAttachment.url]
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error linking previous transcripts:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
|
||||
async function markGmailMessageRead(gmail, msgRef) {
|
||||
await gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
ids: [msgRef.id],
|
||||
removeLabelIds: ['UNREAD', 'INBOX']
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the error indicates a permanent OAuth-grant failure (invalid_grant /
|
||||
* invalid_client), suspend polling, clear the recurring poll interval, log,
|
||||
* and DM the admin once. Returns true iff polling was suspended (caller
|
||||
* should not treat as a transient retry-on-next-tick error).
|
||||
*
|
||||
* Transient 401/403/429/5xx and network errors are NOT considered permanent —
|
||||
* they fall through to the next interval naturally. The OAuth code lives on
|
||||
* `err.response.data.error`, not the message string.
|
||||
*/
|
||||
function oauthSuspendIfPermanent(err, client) {
|
||||
const oauthError = err && err.response && err.response.data && err.response.data.error;
|
||||
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
|
||||
if (!isPermanentAuth) return false;
|
||||
|
||||
pollSuspended = true;
|
||||
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
|
||||
console.error('[gmail-poll]', suspendMsg);
|
||||
logError('Gmail OAuth', { message: suspendMsg, stack: err.stack || err.message || String(err) }, null, client).catch(() => {});
|
||||
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
|
||||
if (CONFIG.ADMIN_ID && !authErrorNotified) {
|
||||
authErrorNotified = true;
|
||||
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Orchestrator
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Poll Gmail for unread primary-inbox messages and route them to Discord.
|
||||
* @param {import('discord.js').Client} client
|
||||
@@ -47,341 +269,137 @@ async function poll(client) {
|
||||
if (isPolling || pollSuspended) return;
|
||||
isPolling = true;
|
||||
try {
|
||||
pollCount++;
|
||||
if (pollCount % 10 === 0) {
|
||||
if (totalProcessed > 0 || totalSkipped > 0 || totalErrors > 0) {
|
||||
logAutomation('Gmail poll summary', null, `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`).catch(() => {});
|
||||
}
|
||||
pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0;
|
||||
}
|
||||
console.log('Running poll()...');
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const list = await gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
q: 'is:unread category:primary'
|
||||
});
|
||||
if (!list.data.messages) return;
|
||||
|
||||
let guild;
|
||||
if (CONFIG.DISCORD_GUILD_ID) {
|
||||
guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||
if (!guild) {
|
||||
console.warn(
|
||||
'Configured guild not found for DISCORD_GUILD_ID:',
|
||||
CONFIG.DISCORD_GUILD_ID
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
guild = client.guilds.cache.first();
|
||||
if (!guild) {
|
||||
console.warn('No guilds in cache; skipping poll iteration.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const msgRef of list.data.messages) {
|
||||
const email = await gmail.users.messages.get({
|
||||
const gmail = getGmailClient();
|
||||
const list = await gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
id: msgRef.id
|
||||
q: 'is:unread category:primary'
|
||||
});
|
||||
if (!list.data.messages) return;
|
||||
|
||||
const from =
|
||||
email.data.payload.headers.find(h => h.name === 'From')
|
||||
?.value || '';
|
||||
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
|
||||
totalSkipped++;
|
||||
await gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
ids: [msgRef.id],
|
||||
removeLabelIds: ['UNREAD', 'INBOX']
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const guild = locateGuild(client);
|
||||
if (!guild) return;
|
||||
|
||||
const subject =
|
||||
email.data.payload.headers.find(h => h.name === 'Subject')
|
||||
?.value || 'New Ticket';
|
||||
const rawBody = getCleanBody(email.data.payload);
|
||||
for (const msgRef of list.data.messages) {
|
||||
const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
|
||||
const parsed = parseGmailMessage(email);
|
||||
|
||||
const sEmail = extractRawEmail(from).toLowerCase();
|
||||
const sName =
|
||||
(from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
|
||||
?.replace(/"/g, '')
|
||||
.trim() || 'Unknown';
|
||||
if (parsed.isSelf) {
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasReplyHeaderFrom =
|
||||
/(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
|
||||
const looksLikeReply =
|
||||
/\nOn .+wrote:/i.test(rawBody) ||
|
||||
hasReplyHeaderFrom;
|
||||
|
||||
let firstBodyText = rawBody.replace(/\r\n/g, '\n');
|
||||
if (looksLikeReply) {
|
||||
firstBodyText = stripEmailQuotes(firstBodyText);
|
||||
}
|
||||
firstBodyText = stripMobileFooter(firstBodyText);
|
||||
firstBodyText = firstBodyText.replace(/^\s*\n+/g, '');
|
||||
firstBodyText = firstBodyText.replace(/\n{3,}/g, '\n\n');
|
||||
firstBodyText = firstBodyText
|
||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||
.replace(/<\s*$/gm, '')
|
||||
.trim();
|
||||
const firstBody = firstBodyText;
|
||||
|
||||
const rawText = rawBody.replace(/\r\n/g, '\n');
|
||||
let followupBody = stripEmailQuotes(rawText);
|
||||
if (!followupBody.trim()) {
|
||||
followupBody = rawText;
|
||||
}
|
||||
followupBody = followupBody.replace(/^\s*\n*/, '\n');
|
||||
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
|
||||
followupBody = stripMobileFooter(followupBody);
|
||||
followupBody = followupBody
|
||||
.replace(/Get Outlook for [^\n]+/gi, '')
|
||||
.replace(/<\s*$/gm, '')
|
||||
.trim();
|
||||
|
||||
const existing = await Ticket.findOne({ gmailThreadId: email.data.threadId })
|
||||
.select('gmailThreadId discordThreadId status')
|
||||
.lean();
|
||||
const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
|
||||
.select('gmailThreadId discordThreadId status')
|
||||
.lean();
|
||||
|
||||
let ticketChan = null;
|
||||
let parentCategoryIdForTicket = null;
|
||||
let isReopened = false;
|
||||
|
||||
if (existing && existing.discordThreadId) {
|
||||
ticketChan = await guild.channels
|
||||
.fetch(existing.discordThreadId)
|
||||
.catch(() => null);
|
||||
} else if (existing && existing.status === 'closed') {
|
||||
isReopened = true;
|
||||
}
|
||||
if (existing && existing.discordThreadId) {
|
||||
ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null);
|
||||
} else if (existing && existing.status === 'closed') {
|
||||
isReopened = true;
|
||||
}
|
||||
|
||||
if (ticketChan) {
|
||||
const truncatedFollowup = followupBody.slice(0, 1800);
|
||||
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
||||
await enqueueSend(
|
||||
ticketChan,
|
||||
{
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
|
||||
if (ticketChan) {
|
||||
// Append follow-up to existing channel.
|
||||
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
|
||||
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
|
||||
await enqueueSend(ticketChan, {
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
|
||||
allowedMentions: { parse: ['roles'] }
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Check ticket limits before creating
|
||||
const limitCheck = await checkTicketLimits(sEmail);
|
||||
if (!limitCheck.ok) {
|
||||
totalSkipped++;
|
||||
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
|
||||
await gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
ids: [msgRef.id],
|
||||
removeLabelIds: ['UNREAD', 'INBOX']
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Create a new ticket channel.
|
||||
const limitCheck = await checkTicketLimits(parsed.senderEmail);
|
||||
if (!limitCheck.ok) {
|
||||
console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { number } = await getNextTicketNumber(sEmail);
|
||||
const creatorNickname = getSenderLocal(sEmail);
|
||||
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
|
||||
const { number } = await getNextTicketNumber(parsed.senderEmail);
|
||||
const created = await findOrCreateTicketChannel(guild, parsed, number);
|
||||
if (!created) {
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
continue;
|
||||
}
|
||||
ticketChan = created.channel;
|
||||
parentCategoryIdForTicket = created.parentCategoryId;
|
||||
|
||||
try {
|
||||
const routing = await getEmailRouting(guild.id);
|
||||
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
|
||||
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
|
||||
parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null;
|
||||
} else {
|
||||
const parentId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
const detectedGame = detectGame(parsed.subject, parsed.rawBody);
|
||||
const buttons = getTicketActionRow({ escalationTier: 0 });
|
||||
const ticketInfoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields(
|
||||
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false },
|
||||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false },
|
||||
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||
);
|
||||
parentCategoryIdForTicket = parentId;
|
||||
try {
|
||||
ticketChan = await guild.channels.create({
|
||||
name: chanName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId
|
||||
});
|
||||
} catch (createErr) {
|
||||
console.error('Channel create error (email ticket):', createErr);
|
||||
throw createErr;
|
||||
}
|
||||
|
||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||||
embeds: [ticketInfoEmbed],
|
||||
components: [buttons],
|
||||
allowedMentions: { parse: ['roles'] }
|
||||
});
|
||||
|
||||
const { createStaffThread } = require('./services/staffThread');
|
||||
await createStaffThread(ticketChan, client).catch(() => {});
|
||||
|
||||
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||
const { pinMessage } = require('./services/pinMessage');
|
||||
await pinMessage(welcomeMsg, client).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Channel create error (payload):', {
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
rawError: err.rawError
|
||||
|
||||
if (isReopened) {
|
||||
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
|
||||
}
|
||||
|
||||
// Email body is attacker-controlled — no mentions may fire from its content.
|
||||
const truncated = parsed.firstBody.slice(0, 1900);
|
||||
await enqueueSend(ticketChan, {
|
||||
content: `**Message:**\n${truncated}`,
|
||||
allowedMentions: { parse: [] }
|
||||
});
|
||||
await gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
ids: [msgRef.id],
|
||||
removeLabelIds: ['UNREAD', 'INBOX']
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const detectedGame = detectGame(subject, rawBody);
|
||||
// Welcome message skipped for email tickets – the email body speaks for itself.
|
||||
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
|
||||
|
||||
const gameKey =
|
||||
detectedGame && detectedGame !== 'Not Mentioned'
|
||||
? GAME_NAME_TO_KEY[detectedGame] || null
|
||||
: null;
|
||||
|
||||
const buttons = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('close_ticket')
|
||||
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
|
||||
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('claim_ticket')
|
||||
.setLabel(CONFIG.BUTTON_LABEL_CLAIM)
|
||||
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
const ticketInfoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields(
|
||||
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false },
|
||||
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
|
||||
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
|
||||
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
|
||||
);
|
||||
|
||||
enforceEmbedLimit([ticketInfoEmbed]);
|
||||
const welcomeMsg = await enqueueSend(ticketChan, {
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||||
embeds: [ticketInfoEmbed],
|
||||
components: [buttons],
|
||||
allowedMentions: { parse: ['roles'] }
|
||||
});
|
||||
|
||||
const { createStaffThread } = require('./services/staffThread');
|
||||
await createStaffThread(ticketChan, client).catch(() => {});
|
||||
|
||||
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
|
||||
const { pinMessage } = require('./services/pinMessage');
|
||||
await pinMessage(welcomeMsg, client).catch(() => {});
|
||||
}
|
||||
|
||||
// On reopen, link previous transcripts
|
||||
if (isReopened) {
|
||||
try {
|
||||
const transcriptRows = await Transcript.find({ gmailThreadId: email.data.threadId })
|
||||
.sort({ createdAt: 1 })
|
||||
.select('transcriptMessageId')
|
||||
.lean();
|
||||
|
||||
if (transcriptRows.length > 0) {
|
||||
const transcriptChan = await client.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
||||
.catch(() => null);
|
||||
|
||||
if (transcriptChan) {
|
||||
await enqueueSend(
|
||||
ticketChan,
|
||||
`This email thread has ${transcriptRows.length} previous transcript(s):`
|
||||
);
|
||||
|
||||
for (const row of transcriptRows) {
|
||||
const transcriptMsg = await transcriptChan.messages
|
||||
.fetch(row.transcriptMessageId)
|
||||
.catch(() => null);
|
||||
|
||||
if (!transcriptMsg) continue;
|
||||
|
||||
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
|
||||
|
||||
const originalAttachment = transcriptMsg.attachments.first();
|
||||
if (originalAttachment) {
|
||||
await enqueueSend(ticketChan, {
|
||||
content: 'Transcript file:',
|
||||
files: [originalAttachment.url]
|
||||
});
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||
await withRetry(() => Ticket.findOneAndUpdate(
|
||||
{ gmailThreadId: parsed.threadId },
|
||||
{
|
||||
$set: {
|
||||
discordThreadId: ticketChan.id,
|
||||
senderEmail: parsed.senderEmail,
|
||||
subject: parsed.subject,
|
||||
createdAt: now,
|
||||
status: 'open',
|
||||
ticketNumber: number,
|
||||
priority: defaultPriority,
|
||||
lastActivity: now,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error linking previous transcripts:', err);
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
));
|
||||
}
|
||||
|
||||
const truncated = firstBody.slice(0, 1900);
|
||||
// Email body is attacker-controlled — no mentions may fire from its content.
|
||||
await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } });
|
||||
|
||||
// Welcome message skipped for email tickets – the email body speaks for itself.
|
||||
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
|
||||
|
||||
const now = new Date();
|
||||
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
|
||||
|
||||
await withRetry(() => Ticket.findOneAndUpdate(
|
||||
{ gmailThreadId: email.data.threadId },
|
||||
{
|
||||
$set: {
|
||||
discordThreadId: ticketChan.id,
|
||||
senderEmail: sEmail,
|
||||
subject,
|
||||
createdAt: now,
|
||||
status: 'open',
|
||||
ticketNumber: number,
|
||||
priority: defaultPriority,
|
||||
lastActivity: now,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
));
|
||||
totalProcessed++;
|
||||
logGmail(subject, sEmail, number, detectedGame).catch(() => {});
|
||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||
await markGmailMessageRead(gmail, msgRef);
|
||||
}
|
||||
console.log('Archiving/reading Gmail message', msgRef.id);
|
||||
await gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
ids: [msgRef.id],
|
||||
removeLabelIds: ['UNREAD', 'INBOX']
|
||||
}
|
||||
});
|
||||
authErrorNotified = false;
|
||||
} catch (e) {
|
||||
oauthSuspendIfPermanent(e, client);
|
||||
console.error('POLL ERROR:', e);
|
||||
logError('Gmail poll', e, null, client).catch(() => {});
|
||||
}
|
||||
authErrorNotified = false;
|
||||
} catch (e) {
|
||||
// Only treat Google-reported permanent-grant failures as reasons to suspend
|
||||
// the loop. Transient 401/403/429/5xx/network errors fall through to the
|
||||
// next interval tick naturally. The OAuth error codes come back on the
|
||||
// response body, not the message string.
|
||||
const oauthError = e && e.response && e.response.data && e.response.data.error;
|
||||
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
|
||||
|
||||
if (isPermanentAuth) {
|
||||
pollSuspended = true;
|
||||
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
|
||||
console.error('[gmail-poll]', suspendMsg);
|
||||
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
|
||||
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
|
||||
if (CONFIG.ADMIN_ID && !authErrorNotified) {
|
||||
authErrorNotified = true;
|
||||
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
totalErrors++;
|
||||
console.error('POLL ERROR:', e);
|
||||
logError('Gmail poll', e, null, client).catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
isPolling = false;
|
||||
}
|
||||
|
||||
1217
handlers/buttons.js
1217
handlers/buttons.js
File diff suppressed because it is too large
Load Diff
1088
handlers/commands.js
1088
handlers/commands.js
File diff suppressed because it is too large
Load Diff
128
handlers/commands/close.js
Normal file
128
handlers/commands/close.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Force-close flow: /force-close, /cancel-close, /closetimer, plus the
|
||||
* countdown-elapses finalize step and transcript renderer that the
|
||||
* countdown's setTimeout calls back into.
|
||||
*
|
||||
* Note: the button-driven close path lives in handlers/buttons.js
|
||||
* (handleCloseButton / handleConfirmCloseRequest / runFinalClose).
|
||||
* This module covers the slash-command-driven path only.
|
||||
*/
|
||||
const { AttachmentBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
const { logTicketEvent } = require('../../services/debugLog');
|
||||
const { pendingCloses } = require('../pendingCloses');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleCloseTimer(interaction) {
|
||||
const seconds = parseInt(interaction.options.getString('seconds'), 10);
|
||||
CONFIG.FORCE_CLOSE_TIMER = seconds;
|
||||
logTicketEvent('Close timer updated', [
|
||||
{ name: 'Duration', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleCancelClose(interaction) {
|
||||
const pending = pendingCloses.get(interaction.channel.id);
|
||||
if (!pending) {
|
||||
return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
logTicketEvent('Force-close cancelled', [
|
||||
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
|
||||
{ name: 'Cancelled by', value: interaction.user.tag },
|
||||
{ name: 'Original setter', value: pending.username || 'Unknown' }
|
||||
], interaction).catch(() => {});
|
||||
pendingCloses.delete(interaction.channel.id);
|
||||
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleForceClose(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
if (pendingCloses.has(interaction.channel.id)) {
|
||||
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
|
||||
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
|
||||
|
||||
const channelRef = interaction.channel;
|
||||
const clientRef = interaction.client;
|
||||
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
|
||||
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
|
||||
}
|
||||
|
||||
/** Performs the actual force-close work after the countdown elapses. */
|
||||
async function finalizeForceClose(channelRef, clientRef) {
|
||||
pendingCloses.delete(channelRef.id);
|
||||
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
|
||||
if (!freshTicket || freshTicket.status === 'closed') return;
|
||||
|
||||
try {
|
||||
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
|
||||
// a stale message ID pointing into the now-deleted channel.
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: freshTicket.gmailThreadId },
|
||||
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
|
||||
);
|
||||
|
||||
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
|
||||
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
|
||||
console.error('Transcript error (force-close):', tErr)
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
channelRef.delete('Ticket force-closed').catch(e =>
|
||||
console.error('Failed to delete channel:', e)
|
||||
);
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Force close error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Render and post a closing transcript for a ticket. */
|
||||
async function postTranscript(channelRef, clientRef, freshTicket) {
|
||||
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
|
||||
const messages = await channelRef.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
|
||||
messages
|
||||
.reverse()
|
||||
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
|
||||
.join('\n');
|
||||
|
||||
const file = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${channelRef.name}.txt`
|
||||
});
|
||||
|
||||
const transcriptChan = await clientRef.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
|
||||
.catch(() => null);
|
||||
if (!transcriptChan) return;
|
||||
|
||||
const fmt = (d) => new Date(d).toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const openedStr = fmt(freshTicket.createdAt);
|
||||
const closedStr = fmt(new Date());
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelRef.name)
|
||||
.replace(/\{email\}/g, freshTicket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
|
||||
}
|
||||
|
||||
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };
|
||||
168
handlers/commands/contextMenu.js
Normal file
168
handlers/commands/contextMenu.js
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Right-click "Apps" menu commands:
|
||||
* - "Create Ticket From Message" — turn a Discord message into a ticket.
|
||||
* - "View User Tickets" — show last 10 tickets for the targeted user.
|
||||
*/
|
||||
const {
|
||||
ChannelType,
|
||||
EmbedBuilder,
|
||||
MessageFlags,
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { getPriorityEmoji } = require('../../utils');
|
||||
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets');
|
||||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleCreateTicketFromMessage(interaction) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
|
||||
if (!rateLimit.allowed) {
|
||||
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
|
||||
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = interaction.targetMessage;
|
||||
const subject = `Message from ${message.author.tag}`;
|
||||
const description = message.content || 'No content';
|
||||
|
||||
const guild = interaction.guild;
|
||||
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
|
||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||
|
||||
let parentCategoryIdForTicket;
|
||||
try {
|
||||
parentCategoryIdForTicket = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
||||
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
||||
}
|
||||
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentCategoryIdForTicket,
|
||||
permissionOverwrites: [
|
||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
{
|
||||
id: message.author.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('guild.channels.create (context menu ticket):', err);
|
||||
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
|
||||
}
|
||||
|
||||
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
|
||||
const now = new Date();
|
||||
await Ticket.create({
|
||||
gmailThreadId,
|
||||
discordThreadId: channel.id,
|
||||
senderEmail: message.author.tag,
|
||||
subject,
|
||||
createdAt: now,
|
||||
status: 'open',
|
||||
ticketNumber,
|
||||
priority: 'normal',
|
||||
lastActivity: now,
|
||||
creatorId: message.author.id,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
});
|
||||
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
|
||||
const infoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields(
|
||||
{ name: 'From message', value: `[Jump to message](${message.url})` },
|
||||
{ name: 'Creator', value: message.author.toString(), inline: true },
|
||||
{ name: 'Created by Staff', value: interaction.user.toString(), inline: true },
|
||||
{ name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false }
|
||||
);
|
||||
|
||||
const row = getTicketActionRow({ escalationTier: 0 });
|
||||
|
||||
try {
|
||||
const welcomeMsg = await enqueueSend(channel, {
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
|
||||
embeds: [welcomeEmbed, infoEmbed],
|
||||
components: [row]
|
||||
});
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ discordThreadId: channel.id },
|
||||
{ $set: { welcomeMessageId: welcomeMsg.id } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('welcomeMessageId-save', err);
|
||||
}
|
||||
|
||||
await interaction.editReply(`✅ Ticket created: ${channel}`);
|
||||
} catch (err) {
|
||||
logError('create-ticket-from-message', err, interaction).catch(() => {});
|
||||
await interaction.editReply('❌ Failed to create ticket from message.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewUserTickets(interaction) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
try {
|
||||
const targetUser = interaction.targetUser;
|
||||
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(10)
|
||||
.lean();
|
||||
|
||||
if (!tickets || tickets.length === 0) {
|
||||
return interaction.editReply(`📋 No tickets found for ${targetUser.tag}`);
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`📋 Tickets for ${targetUser.tag}`)
|
||||
.setDescription(`Found ${tickets.length} ticket(s)`)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
|
||||
for (const ticket of tickets.slice(0, 5)) {
|
||||
const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal');
|
||||
const statusEmoji = ticket.status === 'open' ? '🟢' : '🔴';
|
||||
embed.addFields({
|
||||
name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`,
|
||||
value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
if (tickets.length > 5) {
|
||||
embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (err) {
|
||||
logError('view-user-tickets', err, interaction).catch(() => {});
|
||||
await interaction.editReply('❌ Failed to fetch user tickets.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleCreateTicketFromMessage, handleViewUserTickets };
|
||||
213
handlers/commands/escalation.js
Normal file
213
handlers/commands/escalation.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Escalation flows.
|
||||
*
|
||||
* runEscalation / runDeescalation are exported for handlers/buttons.js
|
||||
* (the tier-pick buttons share this code path). handleEscalate /
|
||||
* handleDeescalate are the slash-command entry points.
|
||||
*/
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||
const { sendTicketNotificationEmail } = require('../../services/gmail');
|
||||
const { getTicketActionRow } = require('../../utils/ticketComponents');
|
||||
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
|
||||
const { pinMessage } = require('../../services/pinMessage');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
|
||||
const { fetchLoggingChannel } = require('./helpers');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
|
||||
* validate ticket and currentTier < nextTier, and have already deferred.
|
||||
*/
|
||||
async function runEscalation(interaction, ticket, nextTier) {
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = nextTier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||
|
||||
// Clear claim on escalation
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = true;
|
||||
ticket.escalationTier = nextTier;
|
||||
ticket.claimedBy = null;
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const newName = makeTicketName('escalated', ticket, creatorNickname);
|
||||
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
if (!interaction.channel.isThread() && categoryId) {
|
||||
await enqueueMove(interaction.channel, categoryId);
|
||||
}
|
||||
|
||||
const pendingEmbed = new EmbedBuilder()
|
||||
.setDescription('Ticket will be escalated in a few seconds.')
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
await interaction.editReply({ embeds: [pendingEmbed] });
|
||||
|
||||
const creatorId = isDiscordTicket
|
||||
? (ticket.gmailThreadId.split('-').pop() || '').trim()
|
||||
: null;
|
||||
const creatorMention = creatorId ? `<@${creatorId}>` : '';
|
||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
|
||||
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
|
||||
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
|
||||
await enqueueSend(interaction.channel, {
|
||||
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
|
||||
allowedMentions: { parse: ['users', 'roles'] }
|
||||
});
|
||||
|
||||
const escalationBody = CONFIG.ESCALATION_MESSAGE
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
|
||||
const escalatedEmbed = new EmbedBuilder()
|
||||
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
|
||||
.setDescription(escalationBody)
|
||||
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
|
||||
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
|
||||
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
|
||||
const escalationRow = getTicketActionRow(updatedTicketForRow);
|
||||
const escalationMsg = await enqueueSend(interaction.channel, {
|
||||
content: null,
|
||||
embeds: [escalatedEmbed],
|
||||
components: [escalationRow]
|
||||
});
|
||||
|
||||
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
|
||||
await pinMessage(escalationMsg, interaction.client).catch(() => {});
|
||||
}
|
||||
|
||||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||||
try {
|
||||
const escalatorName = interaction.member?.displayName || interaction.user.username;
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`;
|
||||
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
|
||||
} catch (emailErr) {
|
||||
console.error('Escalation email failed (non-fatal):', emailErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextTier === 2 && ticket.welcomeMessageId) {
|
||||
try {
|
||||
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
|
||||
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
|
||||
} catch (e) {
|
||||
console.error('Failed to update welcome message after escalate:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
|
||||
await enqueueSend(logChan,
|
||||
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
|
||||
async function runDeescalation(interaction, ticket) {
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const newTier = currentTier - 1;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
|
||||
);
|
||||
ticket.escalated = newTier > 0;
|
||||
ticket.escalationTier = newTier;
|
||||
ticket.claimedBy = null;
|
||||
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const state = newTier === 0 ? 'unclaimed' : 'escalated';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
if (!interaction.channel.isThread()) {
|
||||
try {
|
||||
if (newTier === 0) {
|
||||
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
|
||||
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
|
||||
} else if (newTier === 1) {
|
||||
const t2Category = isDiscordTicket
|
||||
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
|
||||
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
|
||||
if (t2Category) await enqueueMove(interaction.channel, t2Category);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Move error (deescalate):', e);
|
||||
}
|
||||
}
|
||||
|
||||
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
|
||||
const deescalateEmbed = new EmbedBuilder()
|
||||
.setColor(0x00BFFF)
|
||||
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
|
||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
|
||||
await enqueueSend(logChan,
|
||||
`${ticketType} ticket ${interaction.channel} de‑escalated to ${tierLabel} by ${interaction.user.tag}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEscalate(interaction) {
|
||||
const level = interaction.options.getString('level');
|
||||
const nextTier = level === '3' ? 2 : 1;
|
||||
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier >= 2) {
|
||||
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (nextTier <= currentTier) {
|
||||
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
const categoryId = nextTier === 1
|
||||
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
|
||||
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
|
||||
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
|
||||
if (!categoryId && !interaction.channel.isThread()) {
|
||||
return interaction.reply({
|
||||
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'escalate', () =>
|
||||
runEscalation(interaction, ticket, nextTier)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeescalate(interaction) {
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
if (currentTier === 0) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
await runDeferred(interaction, 'de-escalate',
|
||||
() => runDeescalation(interaction, ticket),
|
||||
{ flags: MessageFlags.Ephemeral }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate };
|
||||
33
handlers/commands/helpers.js
Normal file
33
handlers/commands/helpers.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cross-submodule helpers for handlers/commands/*.
|
||||
*
|
||||
* Lives at this level (not in index.js) so escalation.js, close.js, etc. can
|
||||
* import without creating circular dependencies with index.js.
|
||||
*/
|
||||
const { MessageFlags } = require('discord.js');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { isStaff } = require('../../utils');
|
||||
|
||||
/**
|
||||
* Reply ephemeral and return true if the interaction is in a guild and the
|
||||
* user is not staff (so the caller should bail).
|
||||
*/
|
||||
async function requireStaffRole(interaction) {
|
||||
if (!interaction.guild) return false;
|
||||
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
|
||||
if (isStaff(interaction.member)) return false;
|
||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
||||
await interaction.reply({
|
||||
content: `This command is only available to the support team (${roleMention}).`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fetch the configured logging channel, or null if unset/missing. */
|
||||
async function fetchLoggingChannel(client) {
|
||||
if (!CONFIG.LOGGING_CHANNEL_ID) return null;
|
||||
return client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
|
||||
}
|
||||
|
||||
module.exports = { requireStaffRole, fetchLoggingChannel };
|
||||
346
handlers/commands/index.js
Normal file
346
handlers/commands/index.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Slash command, context menu, and autocomplete dispatcher.
|
||||
*
|
||||
* Submodules own command handlers by topic:
|
||||
* helpers.js — requireStaffRole, fetchLoggingChannel
|
||||
* escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
|
||||
* close.js — handleForceClose, handleCancelClose, handleCloseTimer (+ finalize/transcript)
|
||||
* response.js — /response subcommands + handleAutocomplete
|
||||
* panel.js — handlePanel, handleSignature
|
||||
* contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
|
||||
*
|
||||
* This file holds the dispatchers, the small "remainder" handlers
|
||||
* (channel-mod, settings toggles, /help, /notifydm), and the public
|
||||
* module.exports surface that handlers/buttons.js + broccolini-discord.js
|
||||
* import from `require('./commands')`.
|
||||
*/
|
||||
const { EmbedBuilder, MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { isStaff } = require('../../utils');
|
||||
const { setNotifyDm } = require('../../services/staffSettings');
|
||||
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
|
||||
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
|
||||
const { logError, logTicketEvent } = require('../../services/debugLog');
|
||||
const { findTicketForChannel } = require('../sharedHelpers');
|
||||
|
||||
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
|
||||
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation');
|
||||
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
|
||||
const { handleResponse, handleAutocomplete } = require('./response');
|
||||
const { handlePanel, handleSignature } = require('./panel');
|
||||
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
// ============================================================
|
||||
// Remainder handlers — small enough not to deserve their own module.
|
||||
// ============================================================
|
||||
|
||||
async function handleNotifyDm(interaction) {
|
||||
try {
|
||||
const setting = interaction.options.getString('setting') === 'on';
|
||||
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
|
||||
await interaction.reply({
|
||||
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('notifydm error:', err);
|
||||
await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front: enqueueOverwrite serializes behind any pending rename/move
|
||||
// on this channel and can exceed Discord's 3s interaction-token window.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, {
|
||||
ViewChannel: true,
|
||||
SendMessages: true,
|
||||
ReadMessageHistory: true
|
||||
});
|
||||
await interaction.editReply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Add user error:', err);
|
||||
await interaction.editReply({ content: 'Failed to add user.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(interaction) {
|
||||
const user = interaction.options.getUser('user');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front — same reason as handleAdd.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
|
||||
await interaction.editReply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
|
||||
} catch (err) {
|
||||
console.error('Remove user error:', err);
|
||||
await interaction.editReply({ content: 'Failed to remove user.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTransfer(interaction) {
|
||||
const member = interaction.options.getUser('member');
|
||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Cache-first member resolution; falls back to a fetch if not in cache.
|
||||
// GuildMembers intent keeps the cache warm in normal operation.
|
||||
const guildMember = interaction.guild.members.cache.get(member.id)
|
||||
|| await interaction.guild.members.fetch(member.id).catch(() => null);
|
||||
|
||||
// Reject self-transfers and bots; require the target to satisfy isStaff(),
|
||||
// which covers ROLE_ID_TO_PING + ADDITIONAL_STAFF_ROLES — the same staff
|
||||
// definition used by every other gate in the bot. The previous check only
|
||||
// looked at ROLE_TO_PING_ID, missing additional staff roles.
|
||||
if (!guildMember || guildMember.user.bot || !isStaff(guildMember)) {
|
||||
return interaction.reply({
|
||||
content: 'The target member must have the staff role.',
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
if (guildMember.id === interaction.user.id) {
|
||||
return interaction.reply({
|
||||
content: 'You cannot transfer the ticket to yourself.',
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
// Defer before the DB write + rename so the interaction token survives.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
const claimerLabel = guildMember.displayName || guildMember.user.username;
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
|
||||
);
|
||||
ticket.claimedBy = claimerLabel;
|
||||
ticket.claimerId = guildMember.id;
|
||||
|
||||
// Rename the channel to reflect the new claimer — mirrors the /claim
|
||||
// button flow (applyClaim in handlers/buttons.js). Picks the new
|
||||
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
|
||||
// variant when tier >= 1.
|
||||
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
|
||||
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
|
||||
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
|
||||
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
|
||||
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
|
||||
.catch(err => logError('rename', err).catch(() => {}));
|
||||
|
||||
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
|
||||
await interaction.editReply({
|
||||
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan, {
|
||||
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
|
||||
allowedMentions: { parse: ['users'] }
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transfer error:', err);
|
||||
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMove(interaction) {
|
||||
const category = interaction.options.getChannel('category');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front — enqueueMove serializes behind any pending rename and
|
||||
// setParent itself can take a moment on busy channels.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueMove(interaction.channel, category.id);
|
||||
await interaction.editReply(`Moved ticket to **${category.name}**.`);
|
||||
|
||||
const logChan = await fetchLoggingChannel(interaction.client);
|
||||
if (logChan) {
|
||||
await enqueueSend(logChan,
|
||||
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Move error:', err);
|
||||
await interaction.editReply({ content: 'Failed to move ticket.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTopic(interaction) {
|
||||
const text = interaction.options.getString('text');
|
||||
const ticket = await findTicketForChannel(interaction);
|
||||
if (!ticket) return;
|
||||
|
||||
// Defer up front — enqueueTopic serializes behind any pending rename/move.
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
await enqueueTopic(interaction.channel, text);
|
||||
await interaction.editReply('Topic updated successfully.');
|
||||
} catch (err) {
|
||||
console.error('Topic error:', err);
|
||||
await interaction.editReply({ content: 'Failed to update topic.' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStaffThread(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
if (sub === 'toggle') {
|
||||
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
|
||||
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'name') {
|
||||
const name = interaction.options.getString('thread_name').slice(0, 100);
|
||||
CONFIG.STAFF_THREAD_NAME = name;
|
||||
return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'autorole') {
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
|
||||
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePinMessages(interaction) {
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const enabled = interaction.options.getBoolean('enabled');
|
||||
if (sub === 'initial') {
|
||||
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'escalation') {
|
||||
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
|
||||
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
if (sub === 'suppress') {
|
||||
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
|
||||
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGmailPoll(interaction) {
|
||||
const requested = parseInt(interaction.options.getString('interval'), 10);
|
||||
// Defense-in-depth: the slash command's addChoices already floors at 30s, but
|
||||
// clamp the resolved ms here too so any future caller (or skewed input) can't
|
||||
// drop below 30s and trip Gmail's per-user quota under sustained load.
|
||||
const ms = Math.max(30000, requested * 1000);
|
||||
const seconds = ms / 1000;
|
||||
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
|
||||
const { setGmailPollInterval } = require('../../broccolini-discord');
|
||||
setGmailPollInterval(ms);
|
||||
logTicketEvent('Gmail poll interval updated', [
|
||||
{ name: 'Interval', value: `${seconds}s` },
|
||||
{ name: 'Set by', value: interaction.user.tag }
|
||||
], interaction).catch(() => {});
|
||||
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
async function handleHelp(interaction) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket System - Commands')
|
||||
.setColor(CONFIG.EMBED_COLOR_OPEN)
|
||||
.addFields([
|
||||
{
|
||||
name: 'User Management',
|
||||
value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket'
|
||||
},
|
||||
{
|
||||
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'
|
||||
},
|
||||
{
|
||||
name: 'Saved Responses',
|
||||
value: '`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
|
||||
},
|
||||
{
|
||||
name: 'Variables (for responses)',
|
||||
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
|
||||
},
|
||||
{
|
||||
name: 'Panel System',
|
||||
value: '`/panel #channel` - Create a ticket panel for Discord-side tickets'
|
||||
},
|
||||
{
|
||||
name: 'Escalation',
|
||||
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
|
||||
}
|
||||
])
|
||||
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
|
||||
|
||||
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dispatch tables
|
||||
// ============================================================
|
||||
|
||||
const COMMAND_HANDLERS = {
|
||||
escalate: handleEscalate,
|
||||
deescalate: handleDeescalate,
|
||||
notifydm: handleNotifyDm,
|
||||
add: handleAdd,
|
||||
remove: handleRemove,
|
||||
transfer: handleTransfer,
|
||||
move: handleMove,
|
||||
staffthread: handleStaffThread,
|
||||
pinmessages: handlePinMessages,
|
||||
gmailpoll: handleGmailPoll,
|
||||
closetimer: handleCloseTimer,
|
||||
'cancel-close': handleCancelClose,
|
||||
'force-close': handleForceClose,
|
||||
topic: handleTopic,
|
||||
response: handleResponse,
|
||||
signature: handleSignature,
|
||||
help: handleHelp,
|
||||
panel: handlePanel
|
||||
};
|
||||
|
||||
const CONTEXT_MENU_HANDLERS = {
|
||||
'Create Ticket From Message': handleCreateTicketFromMessage,
|
||||
'View User Tickets': handleViewUserTickets
|
||||
};
|
||||
|
||||
/**
|
||||
* Slash-command dispatcher. Every command is staff-only — including /help,
|
||||
* which previously bypassed the role check.
|
||||
*/
|
||||
async function handleCommand(interaction) {
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
const handler = COMMAND_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
/** Context-menu dispatcher. All entries are staff-only. */
|
||||
async function handleContextMenu(interaction) {
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
const handler = CONTEXT_MENU_HANDLERS[interaction.commandName];
|
||||
if (handler) await handler(interaction);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleCommand,
|
||||
handleContextMenu,
|
||||
handleAutocomplete,
|
||||
runEscalation,
|
||||
runDeescalation
|
||||
};
|
||||
133
handlers/commands/panel.js
Normal file
133
handlers/commands/panel.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* /panel — create a ticket-creation panel embed in a chosen channel.
|
||||
* Also hosts /signature (modal for staff personal email signature) since
|
||||
* both are user-facing UX-flow commands without their own dedicated module.
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
MessageFlags,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { enqueueSend } = require('../../services/channelQueue');
|
||||
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
|
||||
async function handlePanel(interaction) {
|
||||
const channel = interaction.options.getChannel('channel');
|
||||
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null
|
||||
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
|
||||
const description = interaction.options.getString('description') ||
|
||||
'Need help? Click below to create a ticket. 🎟';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(0x2ecc71)
|
||||
.setThumbnail(CONFIG.LOGO_URL || null)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
const row = buildPanelButtonRow(panelType);
|
||||
|
||||
try {
|
||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||
await interaction.reply({ content: `Panel created in ${channel}!`, flags: MessageFlags.Ephemeral });
|
||||
} catch (err) {
|
||||
console.error('Panel creation error:', err);
|
||||
await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
function buildPanelButtonRow(panelType) {
|
||||
if (panelType === 'both') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket (channel)')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
}
|
||||
if (panelType === 'thread') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵')
|
||||
);
|
||||
}
|
||||
if (panelType === 'category') {
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
}
|
||||
return new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('✅')
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSignature(interaction) {
|
||||
try {
|
||||
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`signature_modal_${interaction.user.id}`)
|
||||
.setTitle('Staff Signature Settings');
|
||||
|
||||
const valedictionInput = new TextInputBuilder()
|
||||
.setCustomId('valediction')
|
||||
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.valediction || '');
|
||||
|
||||
const displayNameInput = new TextInputBuilder()
|
||||
.setCustomId('display_name')
|
||||
.setLabel('Display Name (e.g. "Support Team")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.displayName || '');
|
||||
|
||||
const taglineInput = new TextInputBuilder()
|
||||
.setCustomId('tagline')
|
||||
.setLabel('Tagline (e.g. "Technical Support Specialist")')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setRequired(false)
|
||||
.setValue(existingSignature?.tagline || '');
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder().addComponents(valedictionInput),
|
||||
new ActionRowBuilder().addComponents(displayNameInput),
|
||||
new ActionRowBuilder().addComponents(taglineInput)
|
||||
);
|
||||
|
||||
await interaction.showModal(modal);
|
||||
} catch (err) {
|
||||
console.error('Signature command error:', err);
|
||||
if (!interaction.replied && !interaction.deferred) {
|
||||
await interaction.reply({ content: 'Failed to open signature settings.', flags: MessageFlags.Ephemeral }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handlePanel, handleSignature };
|
||||
165
handlers/commands/response.js
Normal file
165
handlers/commands/response.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* /response (saved tags) and its autocomplete.
|
||||
*
|
||||
* /response is itself a router over its subcommands:
|
||||
* send / create / edit / delete / list
|
||||
* The autocomplete handler also lives here since the only autocompleting
|
||||
* slash command is /response.
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
MessageFlags
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../../db-connection');
|
||||
const { CONFIG } = require('../../config');
|
||||
const { replaceVariables } = require('../../utils');
|
||||
const { logError } = require('../../services/debugLog');
|
||||
|
||||
const Tag = mongoose.model('Tag');
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
async function handleResponse(interaction) {
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
const handler = RESPONSE_SUBCOMMANDS[subcommand];
|
||||
if (!handler) return;
|
||||
try {
|
||||
await handler(interaction);
|
||||
} catch (err) {
|
||||
logError('response-command', err, interaction).catch(() => {});
|
||||
const errorMsg = '❌ An error occurred while processing the response command.';
|
||||
if (interaction.deferred) {
|
||||
await interaction.editReply(errorMsg);
|
||||
} else {
|
||||
await interaction.reply({ content: errorMsg, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseSend(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const tag = await Tag.findOne({ name }).lean();
|
||||
if (!tag) {
|
||||
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
const context = {
|
||||
ticket: ticket || {},
|
||||
staff: {
|
||||
username: interaction.user.username,
|
||||
displayName: interaction.member?.displayName,
|
||||
mention: interaction.user.toString()
|
||||
},
|
||||
guild: interaction.guild
|
||||
};
|
||||
|
||||
const content = replaceVariables(tag.content, context);
|
||||
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
|
||||
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
|
||||
// Disable mention parsing so a `@everyone` in a tag body never pings.
|
||||
await interaction.reply({ content, allowedMentions: { parse: [] } });
|
||||
}
|
||||
|
||||
async function handleResponseCreate(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
try {
|
||||
await Tag.create({ name, content, createdBy: interaction.user.id });
|
||||
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, flags: MessageFlags.Ephemeral });
|
||||
} catch (err) {
|
||||
if (err.code === 11000 || err.message?.includes('duplicate')) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
logError('tag-create', err, interaction).catch(() => {});
|
||||
await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseEdit(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
const content = interaction.options.getString('content');
|
||||
|
||||
try {
|
||||
const result = await Tag.updateOne({ name }, { $set: { content } });
|
||||
if (result.matchedCount === 0) {
|
||||
await interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
|
||||
} else {
|
||||
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
} catch (err) {
|
||||
logError('tag-edit', err, interaction).catch(() => {});
|
||||
await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponseDelete(interaction) {
|
||||
const name = interaction.options.getString('name');
|
||||
// Use :: delimiter so tag names with underscores parse correctly (Discord customId max 100 chars).
|
||||
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
|
||||
const confirmRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(customId)
|
||||
.setLabel('Yes, Delete Tag')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('cancel_delete_tag')
|
||||
.setLabel('Cancel')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
return interaction.reply({
|
||||
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
|
||||
components: [confirmRow],
|
||||
flags: MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
async function handleResponseList(interaction) {
|
||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
||||
|
||||
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
|
||||
if (!tags || tags.length === 0) {
|
||||
return interaction.editReply({ content: '📋 No tags available.' });
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('📋 Available Saved Responses')
|
||||
.setDescription(
|
||||
tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n')
|
||||
)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setFooter({ text: `Total: ${tags.length} tags` });
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
|
||||
const RESPONSE_SUBCOMMANDS = {
|
||||
send: handleResponseSend,
|
||||
create: handleResponseCreate,
|
||||
edit: handleResponseEdit,
|
||||
delete: handleResponseDelete,
|
||||
list: handleResponseList
|
||||
};
|
||||
|
||||
/** Autocomplete handler. Currently only /response uses it. */
|
||||
async function handleAutocomplete(interaction) {
|
||||
if (interaction.commandName !== 'response') return;
|
||||
const subcommand = interaction.options.getSubcommand();
|
||||
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
|
||||
|
||||
const focusedValue = interaction.options.getFocused();
|
||||
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
|
||||
const filtered = tags
|
||||
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
|
||||
.slice(0, 25)
|
||||
.map(t => ({ name: t.name, value: t.name }));
|
||||
|
||||
await interaction.respond(filtered);
|
||||
}
|
||||
|
||||
module.exports = { handleResponse, handleAutocomplete };
|
||||
@@ -3,10 +3,11 @@
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail } = require('../utils');
|
||||
const { extractRawEmail, isStaff } = require('../utils');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { updateTicketActivity } = require('../services/tickets');
|
||||
const { getNotifyDm } = require('../services/staffSettings');
|
||||
const { logError } = require('../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
@@ -19,19 +20,22 @@ async function handleDiscordReply(m) {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
|
||||
if (!ticket) return;
|
||||
|
||||
// Track whether last message is from staff or customer
|
||||
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
|
||||
const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
|
||||
const isStaffMember = isStaff(memberForCheck);
|
||||
Ticket.updateOne(
|
||||
{ discordThreadId: m.channel.id },
|
||||
{ $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } }
|
||||
).catch(() => {});
|
||||
{ $set: { lastActivity: new Date() } }
|
||||
).catch(err => logError('updateActivity', err).catch(() => {}));
|
||||
|
||||
// DM the claimer if they have notifydm on and a non-staff user replied.
|
||||
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
|
||||
const dmEnabled = await getNotifyDm(ticket.claimerId);
|
||||
if (dmEnabled) {
|
||||
const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null);
|
||||
// Cache-first: GuildMembers intent keeps the cache populated; only fetch
|
||||
// on miss (e.g. cold cache after restart). Avoids a REST round-trip on
|
||||
// every customer reply in a busy ticket.
|
||||
const staffMember = m.guild.members.cache.get(ticket.claimerId)
|
||||
|| await m.guild.members.fetch(ticket.claimerId).catch(() => null);
|
||||
if (staffMember) {
|
||||
const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`;
|
||||
await staffMember
|
||||
@@ -43,8 +47,6 @@ async function handleDiscordReply(m) {
|
||||
}
|
||||
}
|
||||
|
||||
const discordUser = m.member?.displayName || m.author.username;
|
||||
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +90,6 @@ async function handleDiscordReply(m) {
|
||||
m.content,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
msgId,
|
||||
m.author.id
|
||||
);
|
||||
|
||||
@@ -1,656 +0,0 @@
|
||||
/**
|
||||
* /setup wizard – multi-step panel configuration (panel name, support role,
|
||||
* ticket category, transcript channel, panel channel).
|
||||
*/
|
||||
const {
|
||||
ActionRowBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
EmbedBuilder,
|
||||
ChannelType,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
RoleSelectMenuBuilder,
|
||||
ChannelSelectMenuBuilder
|
||||
} = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { enqueueSend } = require('../services/channelQueue');
|
||||
|
||||
const TOTAL_STEPS = 5;
|
||||
const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
/** @type {Map<string, { step: number, panelName?: string, roleIds?: string[], ticketType?: 'channel'|'thread', categoryId?: string, categoryName?: string, threadChannelId?: string, threadChannelName?: string, transcriptChannelId?: string, panelChannelId?: string, createdAt: number }>} */
|
||||
const setupState = new Map();
|
||||
|
||||
const PREFIX = 'setup_';
|
||||
const PREFIX_BUTTON = PREFIX;
|
||||
const PREFIX_MODAL = PREFIX + 'modal_';
|
||||
const PREFIX_SELECT = PREFIX + 'select_';
|
||||
|
||||
function getState(userId) {
|
||||
const s = setupState.get(userId);
|
||||
if (!s) return null;
|
||||
if (Date.now() - s.createdAt > WIZARD_TIMEOUT_MS) {
|
||||
setupState.delete(userId);
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setState(userId, data) {
|
||||
const existing = setupState.get(userId) || { createdAt: Date.now() };
|
||||
setupState.set(userId, { ...existing, ...data });
|
||||
}
|
||||
|
||||
function clearState(userId) {
|
||||
setupState.delete(userId);
|
||||
}
|
||||
|
||||
function step1Embed(panelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 1/5 Set the panel name')
|
||||
.setDescription(
|
||||
'Use the button to set the panel name and continue.\n(This can be changed later.)'
|
||||
)
|
||||
.addFields({ name: 'Current Name', value: panelName ? `\`${panelName}\`` : 'Not set' });
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'setname')
|
||||
.setLabel('Set name')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('⚙️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_1')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!panelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
function step2Embed(roleLabels) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 2/5 Select the support team role(s)')
|
||||
.setDescription(
|
||||
'The support roles will be automatically added to this panel\'s tickets so they can assist people as needed.\n' +
|
||||
'Use the dropdown to select roles.\n' +
|
||||
'Not seeing your role? Try searching for it inside the dropdown.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Role(s)',
|
||||
value: roleLabels && roleLabels.length ? roleLabels.join(', ') : 'None selected'
|
||||
});
|
||||
|
||||
const select = new RoleSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'roles')
|
||||
.setPlaceholder('Select all the roles for your support team')
|
||||
.setMinValues(1)
|
||||
.setMaxValues(5);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_2')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_2')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!roleLabels || roleLabels.length === 0)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step3Embed(state) {
|
||||
const ticketType = state.ticketType;
|
||||
const categoryName = state.categoryName;
|
||||
const threadChannelName = state.threadChannelName;
|
||||
|
||||
if (!ticketType) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 How should tickets be created?')
|
||||
.setDescription(
|
||||
'**Channels:** Each ticket is a channel in a category (classic layout).\n' +
|
||||
'**Threads:** Each ticket is a private thread under a text channel (compact).\n' +
|
||||
'**Both:** Create one panel with two buttons (thread + category).'
|
||||
)
|
||||
.addFields({ name: 'Choice', value: 'Select below' });
|
||||
|
||||
const row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_channel')
|
||||
.setLabel('Channels in category')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📁'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_thread')
|
||||
.setLabel('Private threads')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_both')
|
||||
.setLabel('Both (thread + category)')
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
.setEmoji('📋'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️')
|
||||
);
|
||||
return { embeds: [embed], components: [row] };
|
||||
}
|
||||
|
||||
if (ticketType === 'both') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select category and thread channel (both)')
|
||||
.setDescription(
|
||||
'The panel will have two buttons: one creates ticket **threads**, one creates ticket **channels**.\n' +
|
||||
'Select the category for channels and the text channel for threads.'
|
||||
)
|
||||
.addFields(
|
||||
{ name: 'Category (for channels)', value: categoryName ? `\`${categoryName}\`` : 'None selected', inline: true },
|
||||
{ name: 'Channel (for threads)', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected', inline: true }
|
||||
);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(
|
||||
new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'category')
|
||||
.setPlaceholder('Select category for channels')
|
||||
.addChannelTypes(ChannelType.GuildCategory)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'thread_channel')
|
||||
.setPlaceholder('Select channel for threads')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1)
|
||||
);
|
||||
const row3 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_both_channel')
|
||||
.setLabel('Channels only')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_thread')
|
||||
.setLabel('Threads only')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!(categoryName && threadChannelName))
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2, row3] };
|
||||
}
|
||||
|
||||
if (ticketType === 'channel') {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select the ticket category')
|
||||
.setDescription(
|
||||
'The selected category is where ticket **channels** will be created.\n' +
|
||||
'Use the dropdown to select the category.'
|
||||
)
|
||||
.addFields({ name: 'Selected Category', value: categoryName ? `\`${categoryName}\`` : 'None selected' });
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'category')
|
||||
.setPlaceholder('Select a category')
|
||||
.addChannelTypes(ChannelType.GuildCategory)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
|
||||
.setLabel('Change to Threads')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!categoryName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
// ticketType === 'thread'
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 3/5 Select the channel for ticket threads')
|
||||
.setDescription(
|
||||
'Ticket **threads** will be created as private threads under the selected text channel.\n' +
|
||||
'Use the dropdown to select the channel.'
|
||||
)
|
||||
.addFields({ name: 'Selected Channel', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected' });
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'thread_channel')
|
||||
.setPlaceholder('Select a text channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
|
||||
.setLabel('Change to Channels')
|
||||
.setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_3')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_3')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!threadChannelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step4Embed(channelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 4/5 Select the transcript channel')
|
||||
.setDescription(
|
||||
'The selected channel is where transcripts will be saved when tickets are closed.\n' +
|
||||
'Use the dropdown to select the channel.\n' +
|
||||
'Not seeing your channel? Try searching for it inside the dropdown.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Channel',
|
||||
value: channelName ? `\`${channelName}\`` : 'Not selected'
|
||||
});
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'transcript')
|
||||
.setPlaceholder('Select a channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_4')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'continue_4')
|
||||
.setLabel('Save & Continue')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!channelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
function step5Embed(channelName) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Step 5/5 Send the panel into a channel')
|
||||
.setDescription(
|
||||
'The ticket creation panel is what the community will use to create tickets.\n' +
|
||||
'Use the dropdown to select the channel to send the panel into.\n' +
|
||||
'Not seeing your channel? Try searching for it inside the dropdown.\n' +
|
||||
'Sending not working? Run `/panel` in the channel directly.'
|
||||
)
|
||||
.addFields({
|
||||
name: 'Selected Channel',
|
||||
value: channelName ? `\`${channelName}\`` : 'Not selected'
|
||||
});
|
||||
|
||||
const select = new ChannelSelectMenuBuilder()
|
||||
.setCustomId(PREFIX_SELECT + 'panel_channel')
|
||||
.setPlaceholder('Select a channel')
|
||||
.addChannelTypes(ChannelType.GuildText)
|
||||
.setMaxValues(1);
|
||||
|
||||
const row1 = new ActionRowBuilder().addComponents(select);
|
||||
const row2 = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'back_5')
|
||||
.setLabel('Back')
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('⬅️'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(PREFIX_BUTTON + 'finish')
|
||||
.setLabel('Finish')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setDisabled(!channelName)
|
||||
);
|
||||
return { embeds: [embed], components: [row1, row2] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /setup slash command – send Step 1.
|
||||
*/
|
||||
async function handleSetupCommand(interaction) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
setState(interaction.user.id, { step: 1, panelName: null });
|
||||
const payload = step1Embed(null);
|
||||
await interaction.editReply(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup button (Set name, Back, Save & Continue, Finish).
|
||||
*/
|
||||
async function handleSetupButton(interaction) {
|
||||
const customId = interaction.customId;
|
||||
if (!customId.startsWith(PREFIX_BUTTON)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Set name → show modal
|
||||
if (customId === PREFIX_BUTTON + 'setname') {
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(PREFIX_MODAL + 'name')
|
||||
.setTitle('Panel name');
|
||||
|
||||
const input = new TextInputBuilder()
|
||||
.setCustomId('panel_name')
|
||||
.setLabel('Panel name')
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder('e.g. New Panel')
|
||||
.setRequired(true)
|
||||
.setMaxLength(100);
|
||||
if (state.panelName) input.setValue(state.panelName);
|
||||
modal.addComponents(new ActionRowBuilder().addComponents(input));
|
||||
await interaction.showModal(modal);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Back
|
||||
if (customId.startsWith(PREFIX_BUTTON + 'back_')) {
|
||||
const step = parseInt(customId.replace(PREFIX_BUTTON + 'back_', ''), 10);
|
||||
const nextStep = step - 1;
|
||||
setState(userId, { step: nextStep });
|
||||
let payload;
|
||||
if (nextStep === 1) payload = step1Embed(state.panelName);
|
||||
else if (nextStep === 2) payload = step2Embed(state.roleLabels);
|
||||
else if (nextStep === 3) payload = step3Embed(state);
|
||||
else if (nextStep === 4) payload = step4Embed(state.transcriptChannelName);
|
||||
else payload = step5Embed(state.panelChannelName);
|
||||
await interaction.update(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save & Continue (steps 1–4)
|
||||
if (customId === PREFIX_BUTTON + 'continue_1') {
|
||||
setState(userId, { step: 2 });
|
||||
await interaction.update(step2Embed(state.roleLabels));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_2') {
|
||||
setState(userId, { step: 3 });
|
||||
await interaction.update(step3Embed({ ...state, step: 3 }));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_channel') {
|
||||
setState(userId, { ticketType: 'channel', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_thread') {
|
||||
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_both') {
|
||||
setState(userId, { ticketType: 'both', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear') {
|
||||
setState(userId, { ticketType: null, categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear_thread') {
|
||||
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'tickettype_clear_both_channel') {
|
||||
setState(userId, { ticketType: 'channel', threadChannelId: null, threadChannelName: null });
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_3') {
|
||||
setState(userId, { step: 4 });
|
||||
await interaction.update(step4Embed(state.transcriptChannelName));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_BUTTON + 'continue_4') {
|
||||
setState(userId, { step: 5 });
|
||||
await interaction.update(step5Embed(state.panelChannelName));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Finish
|
||||
if (customId === PREFIX_BUTTON + 'finish') {
|
||||
const hasTicketTarget =
|
||||
(state.ticketType === 'channel' && state.categoryId) ||
|
||||
(state.ticketType === 'thread' && state.threadChannelId) ||
|
||||
(state.ticketType === 'both' && state.categoryId && state.threadChannelId);
|
||||
if (!state.panelChannelId || !hasTicketTarget || !state.roleIds?.length) {
|
||||
await interaction.reply({
|
||||
content: 'Please complete all steps (panel name, support role, ticket type + category/channel, transcript channel, panel channel).',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const channel = await interaction.client.channels.fetch(state.panelChannelId);
|
||||
const title = state.panelName || 'Indifferent Broccoli Tickets';
|
||||
const description = 'Need help? Click below to create a ticket. 🎟';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(title)
|
||||
.setDescription(description)
|
||||
.setColor(0x2ecc71)
|
||||
.setThumbnail(CONFIG.LOGO_URL || null)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
let row;
|
||||
if (state.ticketType === 'both') {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket (channel)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
} else {
|
||||
row = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setEmoji('✅')
|
||||
);
|
||||
}
|
||||
|
||||
await enqueueSend(channel, { embeds: [embed], components: [row] });
|
||||
|
||||
const envLines = state.ticketType === 'both'
|
||||
? [`DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`, `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`]
|
||||
: [state.ticketType === 'thread'
|
||||
? `DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`
|
||||
: `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`];
|
||||
const envSnippet = [
|
||||
'**Add these to your `.env` file** (optional – only if you want to use these values for new Discord tickets):',
|
||||
'```',
|
||||
...envLines,
|
||||
`ROLE_ID_TO_PING=${state.roleIds[0]}`,
|
||||
`TRANSCRIPT_CHANNEL_ID=${state.transcriptChannelId}`,
|
||||
`LOGGING_CHANNEL_ID=${state.transcriptChannelId}`,
|
||||
'```'
|
||||
].join('\n');
|
||||
|
||||
await interaction.update({
|
||||
embeds: [
|
||||
new EmbedBuilder()
|
||||
.setColor(0x2ecc71)
|
||||
.setTitle('Setup complete')
|
||||
.setDescription(
|
||||
`Panel **${title}** has been sent to ${channel}.\n\n` +
|
||||
envSnippet
|
||||
)
|
||||
],
|
||||
components: []
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Setup finish error:', err);
|
||||
await interaction.reply({
|
||||
content: `Failed to send panel: ${err.message}`,
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
}
|
||||
clearState(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup modal submit (panel name).
|
||||
*/
|
||||
async function handleSetupModal(interaction) {
|
||||
if (!interaction.customId.startsWith(PREFIX_MODAL)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interaction.customId === PREFIX_MODAL + 'name') {
|
||||
const panelName = interaction.fields.getTextInputValue('panel_name').trim();
|
||||
setState(userId, { panelName, step: 1 });
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
const payload = step1Embed(panelName);
|
||||
await interaction.editReply(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle setup select menus (roles, category, transcript channel, panel channel).
|
||||
*/
|
||||
async function handleSetupSelect(interaction) {
|
||||
const customId = interaction.customId;
|
||||
if (!customId.startsWith(PREFIX_SELECT)) return false;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const state = getState(userId);
|
||||
if (!state) {
|
||||
await interaction.reply({
|
||||
content: 'This setup session has expired. Run `/setup` again.',
|
||||
ephemeral: true
|
||||
}).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'roles') {
|
||||
const roles = interaction.roles;
|
||||
const roleIds = [...roles.keys()];
|
||||
const roleLabels = [...roles.values()].map(r => r.name);
|
||||
setState(userId, { roleIds, roleLabels });
|
||||
await interaction.update(step2Embed(roleLabels));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'category') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
categoryId: channel?.id,
|
||||
categoryName: channel?.name
|
||||
});
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
if (customId === PREFIX_SELECT + 'thread_channel') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
threadChannelId: channel?.id,
|
||||
threadChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step3Embed(getState(userId)));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'transcript') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
transcriptChannelId: channel?.id,
|
||||
transcriptChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step4Embed(channel?.name));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (customId === PREFIX_SELECT + 'panel_channel') {
|
||||
const channel = interaction.channels.first();
|
||||
setState(userId, {
|
||||
panelChannelId: channel?.id,
|
||||
panelChannelName: channel?.name
|
||||
});
|
||||
await interaction.update(step5Embed(channel?.name));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PREFIX_BUTTON,
|
||||
PREFIX_MODAL,
|
||||
PREFIX_SELECT,
|
||||
handleSetupCommand,
|
||||
handleSetupButton,
|
||||
handleSetupModal,
|
||||
handleSetupSelect
|
||||
};
|
||||
54
handlers/sharedHelpers.js
Normal file
54
handlers/sharedHelpers.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Shared helpers for slash-command and button handlers.
|
||||
*
|
||||
* Both handlers/commands.js and handlers/buttons.js use these to avoid
|
||||
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
|
||||
*/
|
||||
const { MessageFlags } = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { logError } = require('../services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Look up the ticket linked to this channel; reply with `missingMessage`
|
||||
* (default: "This channel is not linked to a ticket.") and return null if
|
||||
* the channel is not a ticket. Returns the ticket on success.
|
||||
*
|
||||
* @param {import('discord.js').Interaction} interaction
|
||||
* @param {string} [missingMessage]
|
||||
*/
|
||||
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
|
||||
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
|
||||
if (!ticket) {
|
||||
await interaction.reply({ content: missingMessage, flags: MessageFlags.Ephemeral });
|
||||
return null;
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer + run + log + reply on error. `verb` is the user-facing verb
|
||||
* (e.g. "escalate"); error messages render as "Failed to <verb> this ticket."
|
||||
* Errors are logged to console + DEBUGGING_CHANNEL_ID via logError(verb, ...).
|
||||
*
|
||||
* @param {import('discord.js').Interaction} interaction
|
||||
* @param {string} verb
|
||||
* @param {() => Promise<void>} fn
|
||||
* @param {{ flags?: number }} [opts] - pass `MessageFlags.Ephemeral` for ephemeral defer
|
||||
*/
|
||||
async function runDeferred(interaction, verb, fn, { flags } = {}) {
|
||||
try {
|
||||
await interaction.deferReply(flags ? { flags } : {});
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(`${verb} error:`, err);
|
||||
logError(verb, err, interaction).catch(() => {});
|
||||
const msg = `Failed to ${verb} this ticket.`;
|
||||
await interaction.editReply({ content: msg }).catch(() =>
|
||||
interaction.followUp({ content: msg, flags: MessageFlags.Ephemeral }).catch(() => {})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { findTicketForChannel, runDeferred };
|
||||
15
models.js
15
models.js
@@ -5,8 +5,6 @@ var mongoose = require('mongoose');
|
||||
const ticketSchema = new mongoose.Schema({
|
||||
gmailThreadId: { type: String, required: true, unique: true, index: true },
|
||||
discordThreadId: String,
|
||||
broccoliniTicketId: Number,
|
||||
lastSyncedBroccoliniArticleId: Number,
|
||||
senderEmail: { type: String, required: true },
|
||||
subject: String,
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
@@ -16,18 +14,13 @@ const ticketSchema = new mongoose.Schema({
|
||||
escalated: { type: Boolean, default: false },
|
||||
escalationTier: { type: Number, default: 0 },
|
||||
ticketNumber: Number,
|
||||
renameCount: { type: Number, default: 0 },
|
||||
renameWindowStart: Date,
|
||||
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
|
||||
ticketTag: String,
|
||||
lastActivity: Date,
|
||||
reminderSent: { type: Boolean, default: false },
|
||||
welcomeMessageId: String,
|
||||
claimerId: String,
|
||||
staffChannelId: String,
|
||||
creatorId: String,
|
||||
parentCategoryId: String,
|
||||
unclaimedRemindersSent: { type: [Number], default: [] },
|
||||
lastMessageAuthorIsStaff: { type: Boolean, default: false },
|
||||
pendingDelete: { type: Boolean, default: false }
|
||||
});
|
||||
ticketSchema.index({ status: 1, lastActivity: 1 });
|
||||
@@ -54,12 +47,6 @@ mongoose.model('Tag', new mongoose.Schema({
|
||||
useCount: { type: Number, default: 0 }
|
||||
}));
|
||||
|
||||
mongoose.model('GuildSettings', new mongoose.Schema({
|
||||
guildId: { type: String, required: true, unique: true },
|
||||
emailRouting: { type: String, enum: ['thread', 'category'], default: 'category' },
|
||||
updatedAt: { type: Date, default: Date.now }
|
||||
}));
|
||||
|
||||
mongoose.model('StaffSettings', new mongoose.Schema({
|
||||
userId: { type: String, required: true, unique: true },
|
||||
guildId: { type: String, required: true },
|
||||
|
||||
1596
package-lock.json
generated
1596
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,14 @@
|
||||
"dependencies": {
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^11.0.6",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"googleapis": "^171.4.0",
|
||||
"mongoose": "^8.23.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mongodb": "^7.1.0",
|
||||
"mongoose": "^6.12.0",
|
||||
"p-queue": "^6.6.2"
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"name": "broccolini-bot",
|
||||
"version": "1.0.0",
|
||||
@@ -16,6 +17,7 @@
|
||||
"main": "broccolini-discord.js",
|
||||
"scripts": {
|
||||
"start": "node broccolini-discord.js",
|
||||
"test": "vitest run",
|
||||
"test-mongodb": "node scripts/test-mongodb.js"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
@@ -4,7 +4,6 @@ const { ChannelType } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
const { safeEqual } = require('../utils');
|
||||
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
|
||||
const { logSystem } = require('../services/debugLog');
|
||||
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -20,6 +19,17 @@ const internalLimiter = rateLimit({
|
||||
message: { error: 'Too many requests, please try again later.' }
|
||||
});
|
||||
|
||||
// /restart calls process.exit; defense-in-depth tighter floor in case the
|
||||
// shared INTERNAL_API_SECRET ever leaks. 2/min is enough for an operator-
|
||||
// driven retry but not enough to crash-loop the container.
|
||||
const restartLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 2,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many restart attempts.' }
|
||||
});
|
||||
|
||||
router.use(internalLimiter);
|
||||
|
||||
// Middleware: verify internal secret
|
||||
@@ -54,11 +64,6 @@ router.post('/config', express.json(), async (req, res) => {
|
||||
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
|
||||
}
|
||||
const result = applyConfigUpdates(updates);
|
||||
const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', ');
|
||||
await logSystem('Config updated via settings UI', [
|
||||
{ name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false },
|
||||
{ name: 'Errors', value: errorSummary || 'none', inline: false }
|
||||
]).catch(() => {});
|
||||
// Partial success stays 200 so the client can still apply the successful keys.
|
||||
// Only 400 when every submitted key failed validation (i.e. the save did nothing).
|
||||
const totalSubmitted = Object.keys(updates).length;
|
||||
@@ -69,7 +74,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/botClient').getBot();
|
||||
const client = require('../broccolini-discord').client;
|
||||
if (!client) return res.status(503).json({ error: 'Bot not ready' });
|
||||
|
||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||
@@ -117,7 +122,7 @@ router.get('/discord/guild', async (req, res) => {
|
||||
// POST /restart — restart the bot process
|
||||
let scheduledRestart = null;
|
||||
|
||||
router.post('/restart', express.json(), (req, res) => {
|
||||
router.post('/restart', restartLimiter, express.json(), (req, res) => {
|
||||
const { mode, scheduledFor } = req.body;
|
||||
|
||||
if (mode === 'immediate') {
|
||||
@@ -178,9 +183,6 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
|
||||
if (parent.setGmailPollInterval) {
|
||||
parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
|
||||
}
|
||||
await logSystem('Gmail OAuth reloaded', [
|
||||
{ name: 'Account', value: emailAddress, inline: false }
|
||||
]).catch(() => {});
|
||||
res.json({ ok: true, email: emailAddress });
|
||||
} catch (err) {
|
||||
const oauthError = err && err.response && err.response.data && err.response.data.error;
|
||||
|
||||
88
scripts/backfill-creatorId.js
Normal file
88
scripts/backfill-creatorId.js
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* One-shot backfill for Ticket.creatorId on Discord-originated tickets.
|
||||
*
|
||||
* Modal-created tickets (`discord-${ts}-${userId}`): tail segment is the user ID — extract it.
|
||||
* Context-menu tickets (`discord-msg-${ts}-${msgId}`): tail segment is the *message* ID, not the
|
||||
* user ID. Set creatorId = null and let runtime code fall through to the default-name path.
|
||||
* Recovering these would require a Discord API fetch per message, which is unreliable for
|
||||
* already-deleted ticket channels.
|
||||
*
|
||||
* Idempotent: skips tickets that already have creatorId set.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/backfill-creatorId.js # dry-run, prints summary only
|
||||
* node scripts/backfill-creatorId.js --apply # writes
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { connectMongoDB, closeMongoDB, mongoose } = require('../db-connection');
|
||||
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
const MODAL_RE = /^discord-\d+-(\d{17,20})$/;
|
||||
|
||||
async function main() {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
console.error('MONGODB_URI not set');
|
||||
process.exit(1);
|
||||
}
|
||||
await connectMongoDB(process.env.MONGODB_URI);
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
const candidates = await Ticket.find({
|
||||
gmailThreadId: /^discord-/,
|
||||
creatorId: { $in: [null, undefined, ''] }
|
||||
}).select('gmailThreadId creatorId').lean();
|
||||
|
||||
let modalHits = 0;
|
||||
let msgSkipped = 0;
|
||||
let unknown = 0;
|
||||
const ops = [];
|
||||
|
||||
for (const t of candidates) {
|
||||
const id = t.gmailThreadId;
|
||||
const modalMatch = id.match(MODAL_RE);
|
||||
if (modalMatch) {
|
||||
modalHits++;
|
||||
ops.push({
|
||||
updateOne: {
|
||||
filter: { _id: t._id },
|
||||
update: { $set: { creatorId: modalMatch[1] } }
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (id.startsWith('discord-msg-')) {
|
||||
msgSkipped++;
|
||||
continue;
|
||||
}
|
||||
unknown++;
|
||||
}
|
||||
|
||||
console.log(`Scanned ${candidates.length} Discord-originated tickets without creatorId.`);
|
||||
console.log(` Modal-pattern recoverable: ${modalHits}`);
|
||||
console.log(` Context-menu (unrecoverable, leaving null): ${msgSkipped}`);
|
||||
console.log(` Unknown shape: ${unknown}`);
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('\nDry-run only. Re-run with --apply to write changes.');
|
||||
await closeMongoDB();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ops.length === 0) {
|
||||
console.log('Nothing to write.');
|
||||
await closeMongoDB();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await Ticket.bulkWrite(ops, { ordered: false });
|
||||
console.log(`Wrote ${res.modifiedCount} updates.`);
|
||||
await closeMongoDB();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -25,10 +25,13 @@ async function executeRename(channel, entry) {
|
||||
// (403), or no token configured — fall back to the primary Discord.js client.
|
||||
// Non-fallback errors rethrow so enqueueRename's catch can classify/log.
|
||||
if (err && err.fallback === true && channel && typeof channel.setName === 'function') {
|
||||
logWarn(
|
||||
'renameQueue',
|
||||
`secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
||||
).catch(() => {});
|
||||
// Local log only; discord.js's REST client transparently handles 429s
|
||||
// on the primary fallback, so this used to post a paired warning to
|
||||
// the debug channel for every secondary-bot quota event with no
|
||||
// operator action required. Keep the visibility in container logs.
|
||||
console.warn(
|
||||
`[renameQueue] secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
|
||||
);
|
||||
await channel.setName(currentName);
|
||||
} else {
|
||||
throw err;
|
||||
@@ -80,6 +83,12 @@ function enqueueRename(channel, newName) {
|
||||
|
||||
// Shares renameChains so a move+rename pair on the same channel executes in
|
||||
// call order. No coalescing: every move is a distinct chain link.
|
||||
//
|
||||
// lockPermissions: false preserves the channel's existing permission overwrites
|
||||
// across the parent change. With the default (true), Discord re-syncs the
|
||||
// channel's overwrites to match the new category and wipes per-user grants —
|
||||
// in practice that kicked the ticket creator and any /add'd users off the
|
||||
// channel on every escalate / de-escalate / /move.
|
||||
function enqueueMove(channel, categoryId) {
|
||||
let entry = renameChains.get(channel.id);
|
||||
if (!entry) {
|
||||
@@ -87,7 +96,7 @@ function enqueueMove(channel, categoryId) {
|
||||
renameChains.set(channel.id, entry);
|
||||
}
|
||||
|
||||
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: true }));
|
||||
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: false }));
|
||||
entry.chain = next;
|
||||
|
||||
next.catch((err) => {
|
||||
@@ -113,6 +122,81 @@ function enqueueMove(channel, categoryId) {
|
||||
return next;
|
||||
}
|
||||
|
||||
// Shares renameChains so a permissionOverwrite mutation serializes with pending
|
||||
// renames/moves on the same channel. Mode 'create' calls
|
||||
// `channel.permissionOverwrites.create(id, perms)`; 'delete' calls
|
||||
// `channel.permissionOverwrites.delete(id)`. No coalescing.
|
||||
function enqueueOverwrite(channel, id, perms, mode = 'create') {
|
||||
let entry = renameChains.get(channel.id);
|
||||
if (!entry) {
|
||||
entry = { chain: Promise.resolve(), pendingName: null };
|
||||
renameChains.set(channel.id, entry);
|
||||
}
|
||||
|
||||
const next = entry.chain.catch(() => {}).then(() =>
|
||||
mode === 'delete'
|
||||
? channel.permissionOverwrites.delete(id)
|
||||
: channel.permissionOverwrites.create(id, perms)
|
||||
);
|
||||
entry.chain = next;
|
||||
|
||||
next.catch((err) => {
|
||||
logWarn('overwriteQueue', `Overwrite ${mode} failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
|
||||
const status = err && err.status;
|
||||
const msg = (err && err.message) || String(err);
|
||||
if (status === 401 || status === 403) {
|
||||
logError(
|
||||
'overwriteQueue:token/permission',
|
||||
new Error(`${status} channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
|
||||
).catch(() => {});
|
||||
} else if (status === 429) {
|
||||
logError(
|
||||
'overwriteQueue:ratelimited',
|
||||
new Error(`429 channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
|
||||
).catch(() => {});
|
||||
}
|
||||
}).finally(() => {
|
||||
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
|
||||
renameChains.delete(channel.id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// Shares renameChains so setTopic serializes with pending renames/moves.
|
||||
function enqueueTopic(channel, text) {
|
||||
let entry = renameChains.get(channel.id);
|
||||
if (!entry) {
|
||||
entry = { chain: Promise.resolve(), pendingName: null };
|
||||
renameChains.set(channel.id, entry);
|
||||
}
|
||||
|
||||
const next = entry.chain.catch(() => {}).then(() => channel.setTopic(text));
|
||||
entry.chain = next;
|
||||
|
||||
next.catch((err) => {
|
||||
logWarn('topicQueue', `Topic set failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
|
||||
const status = err && err.status;
|
||||
const msg = (err && err.message) || String(err);
|
||||
if (status === 401 || status === 403) {
|
||||
logError(
|
||||
'topicQueue:token/permission',
|
||||
new Error(`${status} channel=${channel.id}: ${msg}`)
|
||||
).catch(() => {});
|
||||
} else if (status === 429) {
|
||||
logError(
|
||||
'topicQueue:ratelimited',
|
||||
new Error(`429 channel=${channel.id}: ${msg}`)
|
||||
).catch(() => {});
|
||||
}
|
||||
}).finally(() => {
|
||||
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
|
||||
renameChains.delete(channel.id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
// Per-channel promise chain for send ordering and to prevent interleaving.
|
||||
const sendChains = new Map();
|
||||
|
||||
@@ -157,4 +241,4 @@ function enqueueDelete(channel) {
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete };
|
||||
module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };
|
||||
|
||||
@@ -150,7 +150,16 @@ function writeEnvFile(updates) {
|
||||
|
||||
const roundtrip = readEnvFile();
|
||||
if (roundtrip.size !== expected) {
|
||||
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
|
||||
const expectedKeys = new Set(updates.keys());
|
||||
const actualKeys = new Set(roundtrip.keys());
|
||||
const missing = [...expectedKeys].filter(k => !actualKeys.has(k));
|
||||
const extra = [...actualKeys].filter(k => !expectedKeys.has(k));
|
||||
throw new Error(
|
||||
`writeEnvFile: key count mismatch after write ` +
|
||||
`(expected ${expected}, got ${roundtrip.size})` +
|
||||
(missing.length ? `. Missing: [${missing.join(', ')}]` : '') +
|
||||
(extra.length ? `. Extra: [${extra.join(', ')}]` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
|
||||
const ALLOWED_CONFIG_KEYS = new Set([
|
||||
// Ticket settings
|
||||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
|
||||
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
|
||||
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
|
||||
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID',
|
||||
// Escalation categories
|
||||
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
|
||||
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
|
||||
@@ -30,13 +28,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'ADMIN_ID',
|
||||
// Channel IDs
|
||||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_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',
|
||||
'RENAME_LOG_CHANNEL_ID',
|
||||
// Messages and labels
|
||||
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
|
||||
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
|
||||
'AUTO_CLOSE_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
||||
'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
|
||||
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
|
||||
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
|
||||
// Branding
|
||||
@@ -48,11 +44,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
|
||||
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
|
||||
// Limits and thresholds
|
||||
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
|
||||
'GLOBAL_TICKET_LIMIT',
|
||||
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
|
||||
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
|
||||
// Embed colors
|
||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
|
||||
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
|
||||
]);
|
||||
|
||||
@@ -203,32 +199,7 @@ function getValidator(key) {
|
||||
return VALIDATORS[inferType(key)];
|
||||
}
|
||||
|
||||
// Pre-build per-key validator map for callers that want O(1) lookup
|
||||
// (and for the smoke test / boot log).
|
||||
const ALL_VALIDATORS = {};
|
||||
for (const key of ALLOWED_CONFIG_KEYS) {
|
||||
ALL_VALIDATORS[key] = getValidator(key);
|
||||
}
|
||||
|
||||
// ---------- Startup log (no-op if console.log is suppressed) ----------
|
||||
|
||||
(function logDistribution() {
|
||||
const dist = {};
|
||||
const fallback = [];
|
||||
for (const [key, v] of Object.entries(ALL_VALIDATORS)) {
|
||||
dist[v.type] = (dist[v.type] || 0) + 1;
|
||||
if (v.type === 'string') fallback.push(key);
|
||||
}
|
||||
console.log('[configSchema] type distribution:', JSON.stringify(dist));
|
||||
if (fallback.length) {
|
||||
console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', '));
|
||||
}
|
||||
})();
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_CONFIG_KEYS,
|
||||
VALIDATORS,
|
||||
ALL_VALIDATORS,
|
||||
getValidator,
|
||||
inferType
|
||||
getValidator
|
||||
};
|
||||
|
||||
@@ -11,6 +11,24 @@ function setClient(c) {
|
||||
client = c;
|
||||
}
|
||||
|
||||
// --- PII redaction ---
|
||||
|
||||
// Email addresses (loose regex — covers most RFC 5321 local parts that show up
|
||||
// in support traffic) and Discord snowflakes (18–20 digit numeric IDs) get
|
||||
// redacted before stack/message text reaches the debug channel. Both can land
|
||||
// in error stacks via senderEmail interpolation, channel IDs in error
|
||||
// messages, etc. — redacting at the boundary keeps the debug channel useful
|
||||
// for triage without leaking customer addresses or staff member IDs.
|
||||
const EMAIL_REDACT_RE = /[\w.+-]+@[\w.-]+\.\w+/g;
|
||||
const SNOWFLAKE_REDACT_RE = /\b\d{18,20}\b/g;
|
||||
|
||||
function redactPII(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(EMAIL_REDACT_RE, '[EMAIL_REDACTED]')
|
||||
.replace(SNOWFLAKE_REDACT_RE, '[ID_REDACTED]');
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
async function sendToChannel(channelId, embed, overrideClient) {
|
||||
@@ -38,9 +56,10 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
||||
const commandLine = (interaction?.commandName || interaction?.customId)
|
||||
? `Command/Button: ${interaction.commandName || interaction.customId}\n`
|
||||
: '';
|
||||
const stack = (error.stack || error.message || String(error)).slice(0, 1500);
|
||||
const message = redactPII(error.message || String(error));
|
||||
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
|
||||
await channel.send({
|
||||
content: `\`[${context}]\` ${error.message || String(error)}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
||||
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore send failures
|
||||
@@ -52,19 +71,12 @@ async function logError(context, error, interaction = null, overrideClient = nul
|
||||
async function logWarn(context, message, overrideClient = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`Warning: ${context}`)
|
||||
.setDescription(String(message).slice(0, 4000))
|
||||
.setDescription(redactPII(String(message)).slice(0, 4000))
|
||||
.setColor(0xFFFF00)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logEvent (generic – posts to any configured channel) ---
|
||||
|
||||
async function logEvent(channelConfigKey, embed, overrideClient = null) {
|
||||
const channelId = CONFIG[channelConfigKey];
|
||||
await sendToChannel(channelId, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logTicketEvent ---
|
||||
|
||||
async function logTicketEvent(action, fields, interaction = null) {
|
||||
@@ -76,95 +88,12 @@ async function logTicketEvent(action, fields, interaction = null) {
|
||||
if (interaction?.user?.tag) {
|
||||
embed.setFooter({ text: interaction.user.tag });
|
||||
}
|
||||
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
|
||||
}
|
||||
|
||||
// --- logGmail ---
|
||||
|
||||
async function logGmail(subject, sender, ticketNumber, game) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Email Ticket Created')
|
||||
.setColor(0x00BFFF)
|
||||
.addFields(
|
||||
{ name: 'Subject', value: String(subject || 'No subject').slice(0, 256), inline: false },
|
||||
{ name: 'Sender', value: String(sender || 'unknown'), inline: true },
|
||||
{ name: 'Ticket #', value: String(ticketNumber || '?'), inline: true },
|
||||
{ name: 'Game', value: String(game || 'Not detected'), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.GMAIL_LOG_CHANNEL_ID, embed);
|
||||
}
|
||||
|
||||
// --- logAutomation ---
|
||||
|
||||
async function logAutomation(action, ticketChannelName, detail) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(action)
|
||||
.setColor(0x9B59B6)
|
||||
.setTimestamp();
|
||||
if (ticketChannelName) {
|
||||
embed.addFields({ name: 'Ticket', value: String(ticketChannelName), inline: true });
|
||||
}
|
||||
if (detail) {
|
||||
embed.addFields({ name: 'Detail', value: String(detail).slice(0, 1024), inline: false });
|
||||
}
|
||||
await sendToChannel(CONFIG.AUTOMATION_LOG_CHANNEL_ID, embed);
|
||||
}
|
||||
|
||||
// --- logSecurity ---
|
||||
|
||||
async function logSecurity(action, user, detail, overrideClient = null, color = 0xFF6600) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Security Event')
|
||||
.setColor(color)
|
||||
.addFields(
|
||||
{ name: 'Action', value: String(action).slice(0, 256), inline: false },
|
||||
{ name: 'User', value: user ? `${user.tag} (${user.id})` : 'Unknown', inline: true },
|
||||
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.SECURITY_LOG_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logIntegrity ---
|
||||
|
||||
async function logIntegrity(issue, detail, overrideClient = null) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle('Ticket Integrity Issue')
|
||||
.setColor(0xFF0000)
|
||||
.addFields(
|
||||
{ name: 'Issue', value: String(issue).slice(0, 256), inline: false },
|
||||
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
)
|
||||
.setTimestamp();
|
||||
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
|
||||
}
|
||||
|
||||
// --- logSystem ---
|
||||
|
||||
async function logSystem(message, fields = [], overrideClient = null, color = 0x0099ff) {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(message)
|
||||
.setColor(color)
|
||||
.setTimestamp();
|
||||
if (fields.length > 0) {
|
||||
embed.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true })));
|
||||
}
|
||||
embed.addFields({ name: 'Timestamp', value: new Date().toISOString(), inline: true });
|
||||
await sendToChannel(CONFIG.SYSTEM_LOG_CHANNEL_ID, embed, overrideClient);
|
||||
await sendToChannel(CONFIG.LOGGING_CHANNEL_ID, embed, interaction?.client);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setClient,
|
||||
logError,
|
||||
logWarn,
|
||||
logEvent,
|
||||
logTicketEvent,
|
||||
logGmail,
|
||||
logAutomation,
|
||||
logSecurity,
|
||||
logIntegrity,
|
||||
logSystem
|
||||
logTicketEvent
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Gmail service – OAuth client, send reply, send ticket-closed email.
|
||||
* Gmail service – OAuth client, send reply, send ticket-closed/notification emails.
|
||||
*/
|
||||
const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
@@ -11,6 +11,35 @@ const { readEnvFile } = require('./configPersistence');
|
||||
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
|
||||
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
|
||||
|
||||
function buildCompanySigHtml() {
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
return `
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="margin-top: 16px;">
|
||||
<tr>
|
||||
<td style="padding-right: 12px; vertical-align: top;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65" alt="Indifferent Broccoli">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px; vertical-align: top; font-size: 13px; color: #333;">
|
||||
Indifferent Broccoli Support<br>
|
||||
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
|
||||
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
|
||||
<br>
|
||||
<em>Host your own game server. Or not... we don't care.</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
function buildCompanySigText() {
|
||||
return [
|
||||
'Indifferent Broccoli Support',
|
||||
'https://indifferentbroccoli.com/',
|
||||
'Join us on Discord: https://discord.gg/2vmfrrtvJY',
|
||||
'',
|
||||
"Host your own game server. Or not... we don't care."
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function getGmailClient() {
|
||||
const auth = new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
@@ -27,8 +56,6 @@ function getGmailClient() {
|
||||
*
|
||||
* Throws if the env file is missing the token, or if the probe call (getProfile)
|
||||
* fails — the caller surfaces the error so the UI can see why.
|
||||
*
|
||||
* @returns {Promise<{emailAddress: string}>}
|
||||
*/
|
||||
async function reloadGmailClient() {
|
||||
const envMap = readEnvFile();
|
||||
@@ -45,188 +72,136 @@ async function reloadGmailClient() {
|
||||
return { emailAddress: profile.data.emailAddress };
|
||||
}
|
||||
|
||||
async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
// Fetch the first message's Subject + Message-ID from a Gmail thread, used to
|
||||
// derive a faithful Re: subject and a proper In-Reply-To/References header.
|
||||
async function fetchThreadSubjectAndMsgId(gmail, threadId) {
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId });
|
||||
const firstMsg = (thread.data.messages || [])[0];
|
||||
const headers = firstMsg?.payload?.headers || [];
|
||||
return {
|
||||
subject: headers.find(h => h.name === 'Subject')?.value || null,
|
||||
msgId: sanitizeHeaderValue(headers.find(h => h.name === 'Message-ID')?.value) || null
|
||||
};
|
||||
} catch (_) {
|
||||
return { subject: null, msgId: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Strip leading "Re:" variants and re-prepend a single one, then RFC 2047 encode.
|
||||
function encodeReplySubject(baseSubject) {
|
||||
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
|
||||
const safe = sanitizeHeaderValue(`Re: ${stripped}`);
|
||||
return `=?utf-8?B?${Buffer.from(safe).toString('base64')}?=`;
|
||||
}
|
||||
|
||||
// Compose and send a multipart/alternative reply on an existing Gmail thread.
|
||||
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) {
|
||||
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
|
||||
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = sigBlocks.text;
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
${buildCompanySigHtml()}
|
||||
</div>`;
|
||||
|
||||
const plainBody = [messageText || ''];
|
||||
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
|
||||
plainBody.push('', ...buildCompanySigText().split('\n'));
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
const headers = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipient}`,
|
||||
`Subject: ${encodedSubject}`,
|
||||
msgId && `In-Reply-To: ${msgId}`,
|
||||
msgId && `References: ${msgId}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from([
|
||||
...headers,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
...plainBody,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody,
|
||||
'',
|
||||
`--${boundary}--`
|
||||
].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({ userId: 'me', requestBody: { raw, threadId } });
|
||||
}
|
||||
|
||||
// Resolve and validate a customer recipient from a ticket's senderEmail.
|
||||
// Returns null and logs if invalid or self-addressed.
|
||||
function resolveCustomerRecipient(ticket, context) {
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return null;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError(`${context}: invalid recipient`, new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
return recipientEmail;
|
||||
}
|
||||
|
||||
async function sendTicketClosedEmail(ticket, closerName, userId = null) {
|
||||
try {
|
||||
const recipient = resolveCustomerRecipient(ticket, 'sendTicketClosedEmail');
|
||||
if (!recipient) return;
|
||||
|
||||
const gmail = getGmailClient();
|
||||
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
||||
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
|
||||
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
|
||||
|
||||
// Send to the ticket sender (customer), not derived from thread (which can be support)
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const lastMsg = [...messages].reverse()[0];
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {
|
||||
/* use ticket.subject and no In-Reply-To if thread fetch fails */
|
||||
}
|
||||
|
||||
const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
finalSubject
|
||||
).toString('base64')}?=`;
|
||||
|
||||
const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${safeCloseMessage}</p>
|
||||
<p style="margin-top: 16px;">${safeCloseSignature}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
msgId ? `References: ${msgId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(rawHeaders.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
await sendThreadedEmail(gmail, {
|
||||
threadId: ticket.gmailThreadId,
|
||||
recipient,
|
||||
encodedSubject,
|
||||
msgId,
|
||||
messageText,
|
||||
userId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket closed email error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// StaffSignature model is registered in models.js; re-import here for use in this file
|
||||
const { mongoose } = require('../db-connection');
|
||||
const StaffSignature = mongoose.model('StaffSignature');
|
||||
|
||||
/**
|
||||
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
|
||||
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
|
||||
* @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated")
|
||||
* @param {string} subjectLine - Fallback subject if the thread can't be queried
|
||||
* @param {string} messageBody - Plain or HTML message body
|
||||
* @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord")
|
||||
* @param {string} [userId] - Discord user ID for signature (optional)
|
||||
*/
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) {
|
||||
try {
|
||||
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
|
||||
if (!recipient) return;
|
||||
|
||||
const gmail = getGmailClient();
|
||||
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
if (!EMAIL_RE.test(recipientEmail)) {
|
||||
logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
|
||||
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support');
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const lastMsg = [...messages].reverse()[0];
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader);
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
|
||||
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
|
||||
// Get staff signature if userId provided
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const serverDisplayName = label;
|
||||
const safeCloseMessage = safeBody;
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${safeCloseMessage}</p>
|
||||
<p style="margin-top: 16px;">${safeCloseSignature}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
msgId ? `References: ${msgId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(rawHeaders.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
await sendThreadedEmail(gmail, {
|
||||
threadId: ticket.gmailThreadId,
|
||||
recipient,
|
||||
encodedSubject,
|
||||
msgId,
|
||||
messageText: messageBody,
|
||||
userId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket notification email error:', err);
|
||||
@@ -234,111 +209,24 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Gmail reply to a ticket
|
||||
* @param {string} threadId - Gmail thread ID
|
||||
* @param {string} replyText - Reply text
|
||||
* @param {string} recipientEmail - Recipient email
|
||||
* @param {string} subject - Subject line
|
||||
* @param {string} discordUser - Discord user name
|
||||
* @param {string} messageId - Message ID (optional)
|
||||
* @param {string} userId - Discord user ID for signature (optional)
|
||||
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId
|
||||
* (typically pulled from the latest non-self message in the thread).
|
||||
*/
|
||||
async function sendGmailReply(
|
||||
threadId,
|
||||
replyText,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
messageId,
|
||||
userId = null
|
||||
) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) {
|
||||
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
|
||||
if (!EMAIL_RE.test(safeRecipient)) {
|
||||
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
const safeMessageId = sanitizeHeaderValue(messageId);
|
||||
const safeSubject = sanitizeHeaderValue(`Re: ${subject}`);
|
||||
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
safeSubject
|
||||
).toString('base64')}?=`;
|
||||
const safeUser = escapeHtml(discordUser);
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const companySignatureText = (CONFIG.SIGNATURE || '').replace(/<br>/g, '\n');
|
||||
|
||||
// Get staff signature if userId provided
|
||||
let signatureBlocks = { text: '', html: '' };
|
||||
if (userId) {
|
||||
signatureBlocks = await getStaffSignatureBlocks(userId);
|
||||
}
|
||||
|
||||
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
|
||||
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
|
||||
const safeStaffSigText = signatureBlocks.text;
|
||||
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
|
||||
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeCompanySigHtml}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const boundary = '000000000000' + Date.now().toString(16);
|
||||
|
||||
const plainBody = [];
|
||||
plainBody.push(replyText);
|
||||
if (safeStaffSigText) {
|
||||
plainBody.push(safeStaffSigText);
|
||||
}
|
||||
plainBody.push('');
|
||||
plainBody.push('------------------------------');
|
||||
plainBody.push('');
|
||||
plainBody.push(companySignatureText);
|
||||
|
||||
const raw = Buffer.from([
|
||||
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
|
||||
`To: ${safeRecipient}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
safeMessageId ? `In-Reply-To: ${safeMessageId}` : '',
|
||||
safeMessageId ? `References: ${safeMessageId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/alternative; boundary="' + boundary + '"',
|
||||
'',
|
||||
'--' + boundary,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
...plainBody,
|
||||
'',
|
||||
'--' + boundary,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody,
|
||||
'',
|
||||
'--' + boundary + '--'
|
||||
].join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId }
|
||||
const gmail = getGmailClient();
|
||||
await sendThreadedEmail(gmail, {
|
||||
threadId,
|
||||
recipient: safeRecipient,
|
||||
encodedSubject: encodeReplySubject(subject || 'Support'),
|
||||
msgId: sanitizeHeaderValue(messageId) || null,
|
||||
messageText: replyText,
|
||||
userId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Guild-specific settings (e.g. email ticket routing).
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
|
||||
const GuildSettings = mongoose.model('GuildSettings');
|
||||
|
||||
/**
|
||||
* Get email ticket routing for a guild. Returns 'thread' or 'category'.
|
||||
* If not set, defaults to 'category'.
|
||||
* @param {string} guildId
|
||||
* @returns {Promise<'thread'|'category'>}
|
||||
*/
|
||||
async function getEmailRouting(guildId) {
|
||||
const doc = await GuildSettings.findOne({ guildId }).select('emailRouting').lean();
|
||||
if (doc && doc.emailRouting) return doc.emailRouting;
|
||||
return 'category';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set email ticket routing for a guild.
|
||||
* @param {string} guildId
|
||||
* @param {'thread'|'category'} value
|
||||
*/
|
||||
async function setEmailRouting(guildId, value) {
|
||||
await GuildSettings.findOneAndUpdate(
|
||||
{ guildId },
|
||||
{ $set: { emailRouting: value, updatedAt: new Date() } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getEmailRouting, setEmailRouting };
|
||||
@@ -31,9 +31,9 @@ async function pinMessage(message, client) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code === 30003) {
|
||||
await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
|
||||
logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
|
||||
} else {
|
||||
await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
|
||||
logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
* is logged via logWarn.
|
||||
* - invitable: false means only staff with MANAGE_THREADS can add additional
|
||||
* members — this is intentional for privacy.
|
||||
* - guild.members.fetch() in addRoleMembersToThread can be slow on large
|
||||
* servers. The 300ms delay between adds avoids the thread member add rate
|
||||
* limit (approximately 5/second).
|
||||
* - addRoleMembersToThread reads from role.members (cache-derived) and only
|
||||
* falls back to a scoped guild.members.fetch on cache miss. The 300ms
|
||||
* delay between adds avoids the thread member add rate limit (~5/sec).
|
||||
* It runs via setImmediate so it doesn't block ticket creation.
|
||||
*/
|
||||
const { ChannelType } = require('discord.js');
|
||||
const { CONFIG } = require('../config');
|
||||
@@ -39,7 +40,11 @@ async function createStaffThread(channel, client) {
|
||||
});
|
||||
|
||||
if (CONFIG.STAFF_THREAD_AUTO_ADD_ROLE && CONFIG.STAFF_THREAD_ROLE_ID) {
|
||||
await addRoleMembersToThread(thread, channel.guild, client);
|
||||
// Run off the critical path — the add loop is rate-limited at 300ms per
|
||||
// member and would block ticket creation for ~15s on a 50-member role.
|
||||
setImmediate(() => {
|
||||
addRoleMembersToThread(thread, channel.guild, client).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
return thread;
|
||||
@@ -48,30 +53,40 @@ async function createStaffThread(channel, client) {
|
||||
if (err.code === 50024 || err.code === 160004) {
|
||||
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
|
||||
}
|
||||
await logError('staffThread:create', err, null, client).catch(() => {});
|
||||
logError('staffThread:create', err, null, client).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all members of the staff role to the thread.
|
||||
*
|
||||
* Prefers role.members (computed from guild.members.cache, kept in sync via
|
||||
* the GuildMembers gateway intent — see broccolini-discord.js intents). Only
|
||||
* falls back to a scoped guild.members.fetch on cache miss (e.g. cold cache
|
||||
* just after restart). Previously called the unscoped guild.members.fetch()
|
||||
* on every ticket creation, which chunked all members of the guild — wasted
|
||||
* gateway/REST budget and added ~15s to ticket creation on busy guilds.
|
||||
*/
|
||||
async function addRoleMembersToThread(thread, guild, client) {
|
||||
try {
|
||||
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
|
||||
if (!role) return;
|
||||
|
||||
await guild.members.fetch();
|
||||
const members = guild.members.cache.filter(m =>
|
||||
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot
|
||||
);
|
||||
let members = role.members.filter(m => !m.user.bot);
|
||||
if (members.size === 0) {
|
||||
// Cache cold (first ticket after restart). withPresences: false skips
|
||||
// the presence sync, which is irrelevant for thread-add and expensive.
|
||||
await guild.members.fetch({ withPresences: false }).catch(() => {});
|
||||
members = role.members.filter(m => !m.user.bot);
|
||||
}
|
||||
|
||||
for (const [, member] of members) {
|
||||
await thread.members.add(member.id).catch(() => {});
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
} catch (err) {
|
||||
await logError('staffThread:addMembers', err, null, client).catch(() => {});
|
||||
logError('staffThread:addMembers', err, null, client).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
* Ticket database helpers – counters, rename, limits, auto-close,
|
||||
* reminders, auto-unclaim, channel creation.
|
||||
*/
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
const { ChannelType } = require('discord.js');
|
||||
const { mongoose, withRetry } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { getPriorityEmoji } = require('../utils');
|
||||
const { logAutomation } = require('../services/debugLog');
|
||||
const { enqueueSend, enqueueDelete } = require('./channelQueue');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
@@ -30,9 +28,6 @@ async function getNextTicketNumber(senderEmail) {
|
||||
// primary bot's 2/10min per-channel budget here; 429s from the secondary
|
||||
// bot surface via utils/renamer.js instead.
|
||||
|
||||
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
|
||||
const RENAME_LIMIT = 2;
|
||||
|
||||
function getSenderLocal(senderEmail) {
|
||||
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
|
||||
}
|
||||
@@ -56,7 +51,12 @@ function toDiscordSafeName(str) {
|
||||
*/
|
||||
async function resolveCreatorNickname(guild, ticket) {
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
const creatorUserId = ticket.gmailThreadId.split('-').pop();
|
||||
// Prefer ticket.creatorId (stored on creation). Legacy fallback parses the
|
||||
// tail segment, which is correct for discord-${ts}-${userId} but returns
|
||||
// the message ID for discord-msg-${ts}-${msgId} — skip the parse for those.
|
||||
const creatorUserId = ticket.creatorId
|
||||
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
|
||||
if (!creatorUserId) return getSenderLocal(ticket.senderEmail);
|
||||
try {
|
||||
const member = await guild.members.fetch(creatorUserId);
|
||||
return member.displayName;
|
||||
@@ -90,16 +90,6 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
|
||||
}
|
||||
}
|
||||
|
||||
// Retained for external callers (bOSScord, scripts). The gate now lives in
|
||||
// the secondary bot's rate bucket; this helper no longer touches Mongo.
|
||||
async function canRename(_ticket) {
|
||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||
}
|
||||
|
||||
function minutesFromMs(ms) {
|
||||
return Math.max(1, Math.ceil(ms / 60000));
|
||||
}
|
||||
|
||||
// --- RATE LIMIT (per-user ticket creation) ---
|
||||
|
||||
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
|
||||
@@ -156,15 +146,6 @@ function escapeCategoryNameForRegex(name) {
|
||||
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getOrCreateTicketCategory instead.
|
||||
* @returns {null}
|
||||
*/
|
||||
function pickTicketCategoryId(guild, categoryIds) {
|
||||
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
|
||||
return null;
|
||||
}
|
||||
|
||||
function countChannelsInCategory(guild, categoryId) {
|
||||
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
|
||||
}
|
||||
@@ -272,148 +253,6 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
|
||||
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
||||
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
||||
if (!parentChannel) {
|
||||
throw new Error('Thread parent channel not found');
|
||||
}
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
await thread.members.add(userId);
|
||||
// Add all members with the support role so they can see and reply in the thread
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
if (memberId === userId) continue; // already added
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
} else {
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||
} catch (e) {
|
||||
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||||
throw new Error('Ticket category not found or could not be allocated');
|
||||
}
|
||||
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: userId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('guild.channels.create (createTicketChannel):', e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
|
||||
* Adds creator and all members with ROLE_ID_TO_PING.
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {number} ticketNumber
|
||||
* @param {string} creatorUserId
|
||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||
*/
|
||||
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
|
||||
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
|
||||
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
|
||||
const parentChannel = guild.channels.cache.get(parentId);
|
||||
if (!parentChannel) throw new Error('Discord thread parent channel not found');
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
await thread.members.add(creatorUserId);
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
if (memberId === creatorUserId) continue;
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
|
||||
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {number} ticketNumber
|
||||
* @param {string} chanName
|
||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||
*/
|
||||
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
|
||||
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
|
||||
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
|
||||
const parentChannel = guild.channels.cache.get(parentId);
|
||||
if (!parentChannel) throw new Error('Email thread parent channel not found');
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: chanName || `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
}
|
||||
|
||||
// --- LIMITS & PERMISSIONS ---
|
||||
|
||||
async function checkTicketLimits(senderEmail) {
|
||||
@@ -430,22 +269,12 @@ async function checkTicketLimits(senderEmail) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function hasBlacklistedRole(member) {
|
||||
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return member.roles.cache.some(role =>
|
||||
CONFIG.BLACKLISTED_ROLES.includes(role.id)
|
||||
);
|
||||
}
|
||||
|
||||
// --- ACTIVITY ---
|
||||
|
||||
async function updateTicketActivity(gmailThreadId) {
|
||||
const now = new Date();
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId },
|
||||
{ $set: { lastActivity: now, reminderSent: false } }
|
||||
{ $set: { lastActivity: new Date() } }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,9 +291,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
lastActivity: { $lt: cutoffTime, $ne: null }
|
||||
}).sort({ createdAt: 1 }).limit(500).lean());
|
||||
|
||||
let checked = 0, closed = 0;
|
||||
for (const ticket of staleTickets) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
@@ -481,63 +308,23 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
{ $set: { status: 'closed', pendingDelete: true } }
|
||||
));
|
||||
|
||||
await sendTicketClosedEmail(ticket, 'Auto-Close System');
|
||||
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
|
||||
|
||||
setTimeout(() => {
|
||||
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
|
||||
const { trackTimeout } = require('../broccolini-discord');
|
||||
trackTimeout(setTimeout(() => {
|
||||
enqueueDelete(channel).then(() => {
|
||||
withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $unset: { pendingDelete: '' } }
|
||||
)).catch(() => {});
|
||||
}).catch(() => {});
|
||||
}, 5000);
|
||||
closed++;
|
||||
}, 5000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function checkReminders(client) {
|
||||
if (!CONFIG.REMINDER_ENABLED) return;
|
||||
|
||||
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
|
||||
status: 'open',
|
||||
lastActivity: { $lt: reminderTime, $ne: null },
|
||||
reminderSent: false
|
||||
}).lean());
|
||||
|
||||
let checked = 0, reminded = 0;
|
||||
for (const ticket of ticketsNeedingReminder) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
const ping = ticket.claimedBy
|
||||
? `<@${ticket.claimedBy}>`
|
||||
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
|
||||
const message = CONFIG.REMINDER_MESSAGE
|
||||
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
|
||||
.replace(/\{ping\}/g, ping);
|
||||
await enqueueSend(channel, message);
|
||||
|
||||
await withRetry(() => Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { reminderSent: true } }
|
||||
));
|
||||
reminded++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function checkAutoUnclaim(client) {
|
||||
@@ -550,9 +337,7 @@ async function checkAutoUnclaim(client) {
|
||||
lastActivity: { $lt: unclaimTime, $ne: null }
|
||||
}).lean());
|
||||
|
||||
let checked = 0, unclaimed = 0;
|
||||
for (const ticket of staleClaimedTickets) {
|
||||
checked++;
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
@@ -569,18 +354,16 @@ async function checkAutoUnclaim(client) {
|
||||
);
|
||||
|
||||
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
|
||||
unclaimed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
|
||||
}
|
||||
|
||||
async function reconcileDeletedTicketChannels(client) {
|
||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
|
||||
if (!guild) return { checked: 0, reconciled: 0 };
|
||||
if (!guild) return;
|
||||
|
||||
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
|
||||
const openTickets = await Ticket.find({
|
||||
@@ -588,9 +371,7 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
discordThreadId: { $ne: null }
|
||||
}).sort({ createdAt: 1 }).limit(500).lean();
|
||||
|
||||
let checked = 0, reconciled = 0;
|
||||
for (const ticket of openTickets) {
|
||||
checked++;
|
||||
try {
|
||||
let channel = guild.channels.cache.get(ticket.discordThreadId);
|
||||
if (!channel) {
|
||||
@@ -601,17 +382,11 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { status: 'closed', discordThreadId: null } }
|
||||
);
|
||||
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
|
||||
reconciled++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
|
||||
}
|
||||
}
|
||||
if (reconciled > 0) {
|
||||
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
|
||||
}
|
||||
return { checked, reconciled };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -621,8 +396,7 @@ async function reconcileDeletedTicketChannels(client) {
|
||||
*/
|
||||
async function resumePendingDeletes(client) {
|
||||
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
|
||||
if (!pending.length) return 0;
|
||||
let resumed = 0;
|
||||
if (!pending.length) return;
|
||||
for (const ticket of pending) {
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
@@ -630,7 +404,6 @@ async function resumePendingDeletes(client) {
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
enqueueDelete(channel).catch(() => {});
|
||||
resumed++;
|
||||
}
|
||||
}
|
||||
Ticket.updateOne(
|
||||
@@ -641,35 +414,22 @@ async function resumePendingDeletes(client) {
|
||||
console.error('resumePendingDeletes error:', e);
|
||||
}
|
||||
}
|
||||
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
|
||||
return resumed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNextTicketNumber,
|
||||
getOrCreateTicketCategory,
|
||||
cleanupEmptyOverflowCategory,
|
||||
createDiscordTicketAsThread,
|
||||
createEmailTicketAsThread,
|
||||
RENAME_WINDOW_MS,
|
||||
RENAME_LIMIT,
|
||||
getSenderLocal,
|
||||
toDiscordSafeName,
|
||||
resolveCreatorNickname,
|
||||
makeTicketName,
|
||||
canRename,
|
||||
minutesFromMs,
|
||||
checkTicketCreationRateLimit,
|
||||
createTicketChannel,
|
||||
checkTicketLimits,
|
||||
hasBlacklistedRole,
|
||||
updateTicketActivity,
|
||||
checkAutoClose,
|
||||
checkReminders,
|
||||
checkAutoUnclaim,
|
||||
reconcileDeletedTicketChannels,
|
||||
resumePendingDeletes,
|
||||
startTicketsSweeps,
|
||||
sweepTicketCreationByUser,
|
||||
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
|
||||
startTicketsSweeps
|
||||
};
|
||||
|
||||
@@ -649,164 +649,6 @@ body::before {
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Notifications section */
|
||||
#s-notifications .notif-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#s-notifications .notif-tab-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-title);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
#s-notifications .notif-tab-btn:hover { color: var(--text); }
|
||||
#s-notifications .notif-tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
#s-notifications .notif-panel.hidden { display: none; }
|
||||
#s-notifications .notif-editor {
|
||||
border: 1px solid var(--border);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
#s-notifications .notif-chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 14px 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
#s-notifications .notif-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--primary);
|
||||
background: var(--primary-dim);
|
||||
color: var(--primary);
|
||||
padding: 5px 12px;
|
||||
font-family: var(--font-title);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
#s-notifications .notif-chip button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-size: 14px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
#s-notifications .notif-chip button:hover { opacity: 1; }
|
||||
#s-notifications .notif-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
#s-notifications .notif-input-row input { width: 220px; }
|
||||
#s-notifications .notif-presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
#s-notifications .notif-presets button,
|
||||
#s-notifications .notif-add-btn {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-title);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
|
||||
}
|
||||
#s-notifications .notif-presets button:hover,
|
||||
#s-notifications .notif-add-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: var(--primary-dim-2);
|
||||
}
|
||||
#s-notifications .notif-trigger { margin-top: 16px; }
|
||||
#s-notifications .notif-trigger summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-title);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 14px;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
#s-notifications .notif-trigger summary::-webkit-details-marker { display: none; }
|
||||
#s-notifications .notif-trigger summary::before {
|
||||
content: '+';
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
#s-notifications .notif-trigger[open] summary::before { content: '−'; }
|
||||
#s-notifications .notif-trigger[open] summary { color: var(--primary); }
|
||||
|
||||
/* Phase 9 — notification enable toggles */
|
||||
#s-notifications .notif-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#s-notifications .notif-toggle-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
#s-notifications .notif-toggle-label {
|
||||
font-family: var(--font-title);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
#s-notifications .notif-per-alert-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.notif-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Logging hint link */
|
||||
.logging-hint { color: var(--text-muted); font-size: 13px; }
|
||||
.logging-hint a {
|
||||
@@ -923,12 +765,6 @@ body::before {
|
||||
.sidebar a { padding: 14px 20px; min-height: 44px; font-size: 12px; }
|
||||
.section-header { padding: 18px 20px; }
|
||||
.smart-select-display { min-height: 44px; }
|
||||
#s-notifications .notif-chip { padding: 8px 12px; }
|
||||
#s-notifications .notif-chip button { min-width: 28px; min-height: 28px; font-size: 18px; }
|
||||
#s-notifications .notif-tab-btn,
|
||||
#s-notifications .notif-add-btn,
|
||||
#s-notifications .notif-presets button { min-height: 40px; padding: 10px 14px; }
|
||||
#s-notifications .notif-input-row input { flex: 1 1 auto; width: auto; min-width: 0; }
|
||||
|
||||
.modal-card { width: calc(100vw - 32px); min-width: 0; max-width: 420px; }
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
<a href="/behavior">Ticket Behavior</a>
|
||||
<a href="/threads">Staff Threads</a>
|
||||
<a href="/pins">Pin Messages</a>
|
||||
<a href="/notifications">Notifications</a>
|
||||
<a href="/logging">Logging</a>
|
||||
<a href="/automation">Automation</a>
|
||||
<a href="/appearance">Appearance</a>
|
||||
@@ -58,7 +57,6 @@
|
||||
<a class="landing-card" href="/behavior"><div class="landing-card-body"><h3>Ticket Behavior</h3><p>Automation, limits, and messages</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/threads"><div class="landing-card-body"><h3>Staff Threads</h3><p>Private staff discussion threads</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/pins"><div class="landing-card-body"><h3>Pin Messages</h3><p>Auto-pin welcome and escalations</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/notifications"><div class="landing-card-body"><h3>Notifications</h3><p>Surge, patterns, unclaimed, chat</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/logging"><div class="landing-card-body"><h3>Logging</h3><p>Log channel configuration</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/automation"><div class="landing-card-body"><h3>Automation</h3><p>Polling intervals and timers</p></div><span class="chevron">›</span></a>
|
||||
<a class="landing-card" href="/appearance"><div class="landing-card-body"><h3>Appearance</h3><p>Colors, labels, emojis</p></div><span class="chevron">›</span></a>
|
||||
@@ -84,21 +82,11 @@
|
||||
<div class="field"><label>Transcript Channel</label><input type="text" data-key="TRANSCRIPT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Logging Channel</label><input type="text" data-key="LOGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Backup/Export Channel</label><input type="text" data-key="BACKUP_EXPORT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Account Info Channel</label><input type="text" data-key="ACCOUNT_INFO_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>All Staff Channel</label><input type="text" data-key="ALL_STAFF_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Chat Alert Channel</label><input type="text" data-key="ALL_STAFF_CHAT_ALERT_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>User Patterns Channel</label><input type="text" data-key="USER_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Game Patterns Channel</label><input type="text" data-key="GAME_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Tag Patterns Channel</label><input type="text" data-key="TAG_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Escalation Patterns Channel</label><input type="text" data-key="ESCALATION_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Staff Patterns Channel</label><input type="text" data-key="STAFF_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Combined Patterns Channel</label><input type="text" data-key="COMBINED_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -112,12 +100,9 @@
|
||||
<div class="field"><label>Discord T2 Category</label><input type="text" data-key="DISCORD_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Email T3 Category</label><input type="text" data-key="EMAIL_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Discord T3 Category</label><input type="text" data-key="DISCORD_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Staff Notification Category</label><input type="text" data-key="STAFF_NOTIFICATION_CATEGORY_ID" data-smart="category"></div>
|
||||
<div class="field"><label>Category Name</label><input type="text" data-key="TICKET_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>T2 Category Name</label><input type="text" data-key="TICKET_T2_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>T3 Category Name</label><input type="text" data-key="TICKET_T3_CATEGORY_NAME"></div>
|
||||
<div class="field"><label>Discord Thread Channel</label><input type="text" data-key="DISCORD_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||
<div class="field"><label>Email Thread Channel</label><input type="text" data-key="EMAIL_THREAD_CHANNEL_ID" data-smart="channel"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -140,9 +125,6 @@
|
||||
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Priority System</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PRIORITY_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Claim Timeout</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="CLAIM_TIMEOUT_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Claim Timeout Hours</label><input type="number" data-key="CLAIM_TIMEOUT_HOURS"></div>
|
||||
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
|
||||
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
@@ -179,216 +161,6 @@
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 8. Notifications -->
|
||||
<div class="section" id="s-notifications">
|
||||
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body">
|
||||
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
|
||||
|
||||
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
|
||||
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
|
||||
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel" data-notif-panel="surge">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="surge">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="surge"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="surge"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="surge"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
|
||||
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
|
||||
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
|
||||
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
|
||||
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
|
||||
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
|
||||
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
|
||||
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
|
||||
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
|
||||
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
|
||||
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
|
||||
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="patterns">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="patterns">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="patterns"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="patterns"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
|
||||
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
|
||||
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
|
||||
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
|
||||
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
|
||||
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="unclaimed">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="unclaimed">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="unclaimed"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="unclaimed"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="notif-panel hidden" data-notif-panel="chat">
|
||||
<div class="notif-toggle-row">
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-master>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">Master (all categories)</span>
|
||||
</div>
|
||||
<div class="notif-toggle-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-category-toggle="chat">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label">All in category</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
|
||||
<div class="notif-editor">
|
||||
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
|
||||
<div class="hint notif-alert-description" data-notif-description="chat"></div>
|
||||
<div class="notif-per-alert-row">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" data-notif-alert>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
|
||||
</div>
|
||||
<div class="notif-chips" data-notif-chips="chat"></div>
|
||||
<div class="notif-input-row">
|
||||
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
|
||||
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
|
||||
</div>
|
||||
<div class="notif-presets" data-notif-presets="chat"></div>
|
||||
</div>
|
||||
<details class="notif-trigger">
|
||||
<summary>Trigger conditions</summary>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
|
||||
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
|
||||
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Logging -->
|
||||
<div class="section" id="s-logging">
|
||||
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">▼</span></div>
|
||||
@@ -429,23 +201,16 @@
|
||||
<div class="field"><label>Close Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLOSE"></div>
|
||||
<div class="field"><label>Claim Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLAIM"></div>
|
||||
<div class="field"><label>Unclaim Emoji</label><input type="text" data-key="BUTTON_EMOJI_UNCLAIM"></div>
|
||||
<div class="field"><label>High Priority Emoji</label><input type="text" data-key="PRIORITY_HIGH_EMOJI"></div>
|
||||
<div class="field"><label>Medium Priority Emoji</label><input type="text" data-key="PRIORITY_MEDIUM_EMOJI"></div>
|
||||
<div class="field"><label>Low Priority Emoji</label><input type="text" data-key="PRIORITY_LOW_EMOJI"></div>
|
||||
<div class="field"><label>Claimer Emoji Fallback</label><input type="text" data-key="CLAIMER_EMOJI_FALLBACK"></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 13. Staff -->
|
||||
<div class="section" id="s-staff">
|
||||
<div class="section-header"><h2>Staff</h2><p>Staff IDs, emojis, and admin settings</p><span class="chevron">▼</span></div>
|
||||
<div class="section-header"><h2>Staff</h2><p>Admin and staff role settings</p><span class="chevron">▼</span></div>
|
||||
<div class="section-body"><div class="field-grid">
|
||||
<div class="field full-width"><label>Staff IDs (comma-separated)</label><input type="text" data-key="STAFF_IDS" data-smart="multi-member"></div>
|
||||
<div class="field"><label>Admin ID</label><input type="text" data-key="ADMIN_ID" data-smart="member"></div>
|
||||
<div class="field full-width"><label>Staff Emojis (userId:emoji, comma-separated)</label><input type="text" data-key="STAFF_EMOJIS"><div class="hint">Format: 123456:emoji,789012:emoji</div></div>
|
||||
<div class="field full-width"><label>Additional Staff Roles (comma-separated)</label><input type="text" data-key="ADDITIONAL_STAFF_ROLES"><div class="hint">Role IDs with staff permissions</div></div>
|
||||
<div class="field full-width"><label>Blacklisted Roles (comma-separated)</label><input type="text" data-key="BLACKLISTED_ROLES"><div class="hint">Role IDs that cannot open tickets</div></div>
|
||||
<div class="field full-width"><label>Unclaimed Reminder Thresholds (hours, comma-separated)</label><input type="text" data-key="UNCLAIMED_REMINDER_THRESHOLDS"><div class="hint">e.g. 1,2,4</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -497,7 +262,6 @@
|
||||
<script defer src="/js/util.js"></script>
|
||||
<script defer src="/js/router.js"></script>
|
||||
<script defer src="/js/fields.js"></script>
|
||||
<script defer src="/js/notifications.js"></script>
|
||||
<script defer src="/js/discord.js"></script>
|
||||
<script defer src="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
document.getElementById('bot-status-dot').className = 'dot online';
|
||||
document.getElementById('bot-status-text').textContent = 'Connected';
|
||||
Fields.populateFields(config);
|
||||
Notifications.initNotificationsEditor(config);
|
||||
Fields.initSmartSelects(config);
|
||||
} catch (e) {
|
||||
document.getElementById('bot-status-dot').className = 'dot offline';
|
||||
|
||||
@@ -1,521 +0,0 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d'];
|
||||
|
||||
const FALLBACK_TAB_KEYS = {
|
||||
surge: [
|
||||
'surge_tickets',
|
||||
'surge_game',
|
||||
'surge_stale',
|
||||
'surge_needs_response',
|
||||
'surge_unclaimed',
|
||||
'surge_tier3_unclaimed',
|
||||
'surge_no_staff'
|
||||
],
|
||||
patterns: [
|
||||
'user_tickets',
|
||||
'user_reopen',
|
||||
'user_crossgame',
|
||||
'game_surge',
|
||||
'game_backlog',
|
||||
'game_resolution',
|
||||
'game_spike',
|
||||
'tag_top',
|
||||
'tag_escalation',
|
||||
'untagged_closes',
|
||||
'tag_game_corr',
|
||||
'user_esc',
|
||||
'game_esc_rate',
|
||||
'rapid_t2_t3',
|
||||
'staff_no_close',
|
||||
'staff_overloaded',
|
||||
'staff_stale',
|
||||
'staff_transfer_rate',
|
||||
'staff_esc',
|
||||
'staff_game_esc',
|
||||
'game_tag_spike',
|
||||
'overnight_gap',
|
||||
'staff_always_esc'
|
||||
],
|
||||
unclaimed: ['unclaimed_reminder'],
|
||||
chat: ['chat_messages', 'chat_time']
|
||||
};
|
||||
|
||||
const FALLBACK_ALERT_DESCRIPTIONS = {
|
||||
surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
|
||||
surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
|
||||
surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
|
||||
surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
|
||||
surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
|
||||
surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
|
||||
surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
|
||||
user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
|
||||
user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
|
||||
user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
|
||||
game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
|
||||
game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
|
||||
game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
|
||||
game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
|
||||
tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
|
||||
tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
|
||||
untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
|
||||
tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
|
||||
user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
|
||||
game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
|
||||
rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.',
|
||||
staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
|
||||
staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
|
||||
staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
|
||||
staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
|
||||
staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
|
||||
staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
|
||||
game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
|
||||
overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
|
||||
staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
|
||||
unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
|
||||
chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
|
||||
chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.'
|
||||
};
|
||||
|
||||
let notificationThresholdsState = {};
|
||||
|
||||
// Phase 9: notification enable state. Fetched from /api/notifications/state
|
||||
// on init. `master` is the global kill switch; `perKey` is a flat map of
|
||||
// alertKey → boolean. Both default to off so a fresh deploy is silent.
|
||||
let enableState = { master: false, perKey: {} };
|
||||
|
||||
// Active sources. Start as fallback; replaced/merged when the bot-side
|
||||
// registry (GET /api/notifications/alerts) returns successfully. On 404 or
|
||||
// network failure the fallbacks remain authoritative.
|
||||
let activeTabKeys = FALLBACK_TAB_KEYS;
|
||||
let activeAlertDescriptions = FALLBACK_ALERT_DESCRIPTIONS;
|
||||
|
||||
async function fetchAlertRegistry() {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/alerts', { credentials: 'same-origin' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
||||
// Accept only if at least one known category is a non-empty array
|
||||
const hasShape = ['surge', 'patterns', 'unclaimed'].some(
|
||||
cat => Array.isArray(data[cat]) && data[cat].length > 0
|
||||
);
|
||||
return hasShape ? data : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEnableState() {
|
||||
try {
|
||||
const res = await fetch('/api/notifications/state', { credentials: 'same-origin' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
||||
if (typeof data.master !== 'boolean') return null;
|
||||
if (!data.perKey || typeof data.perKey !== 'object') return null;
|
||||
return { master: data.master, perKey: { ...data.perKey } };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function postToggle(body) {
|
||||
const res = await fetch('/api/notifications/toggle', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) throw new Error(`toggle ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data || !data.state || typeof data.state.master !== 'boolean' || !data.state.perKey) {
|
||||
throw new Error('bad toggle response');
|
||||
}
|
||||
return { master: data.state.master, perKey: { ...data.state.perKey } };
|
||||
}
|
||||
|
||||
// Merge bot registry with fallback, preserving fallback order for existing
|
||||
// keys (so rapid_t2_t3 and chat keys stay where the UI expects them).
|
||||
// Registry-only keys get appended to their category; registry descriptions
|
||||
// override fallback text.
|
||||
function mergeRegistryWithFallback(registry) {
|
||||
const tabKeys = {};
|
||||
const alertDescriptions = { ...FALLBACK_ALERT_DESCRIPTIONS };
|
||||
Object.keys(FALLBACK_TAB_KEYS).forEach(cat => { tabKeys[cat] = [...FALLBACK_TAB_KEYS[cat]]; });
|
||||
|
||||
Object.entries(registry).forEach(([category, entries]) => {
|
||||
if (!Array.isArray(entries)) return;
|
||||
if (!tabKeys[category]) tabKeys[category] = [];
|
||||
const seen = new Set(tabKeys[category]);
|
||||
for (const e of entries) {
|
||||
if (!e || typeof e.key !== 'string') continue;
|
||||
if (!seen.has(e.key)) {
|
||||
tabKeys[category].push(e.key);
|
||||
seen.add(e.key);
|
||||
}
|
||||
if (typeof e.description === 'string') {
|
||||
alertDescriptions[e.key] = e.description;
|
||||
}
|
||||
}
|
||||
});
|
||||
return { tabKeys, alertDescriptions };
|
||||
}
|
||||
|
||||
function applyMergedRegistry(section, registry) {
|
||||
const merged = mergeRegistryWithFallback(registry);
|
||||
activeTabKeys = merged.tabKeys;
|
||||
activeAlertDescriptions = merged.alertDescriptions;
|
||||
window.Notifications.registry = registry;
|
||||
|
||||
Object.entries(activeTabKeys).forEach(([category, keys]) => {
|
||||
const select = section.querySelector(`[data-notif-category="${category}"]`);
|
||||
if (!select) return;
|
||||
const existing = new Set(Array.from(select.options).map(o => o.value));
|
||||
keys.forEach(key => {
|
||||
if (!existing.has(key)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
option.textContent = toHumanLabel(key);
|
||||
select.appendChild(option);
|
||||
}
|
||||
});
|
||||
renderAlertDescription(category);
|
||||
});
|
||||
}
|
||||
|
||||
function initNotificationsEditor(config) {
|
||||
const section = document.getElementById('s-notifications');
|
||||
if (!section) return;
|
||||
|
||||
const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]');
|
||||
if (!hiddenField) return;
|
||||
|
||||
notificationThresholdsState = parseNotificationThresholdsConfig(config);
|
||||
hiddenField.value = serializeNotificationThresholds(notificationThresholdsState);
|
||||
|
||||
section.querySelectorAll('.notif-tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab));
|
||||
});
|
||||
|
||||
Object.entries(activeTabKeys).forEach(([category, keys]) => {
|
||||
const select = section.querySelector(`[data-notif-category="${category}"]`);
|
||||
const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`);
|
||||
const input = section.querySelector(`[data-notif-input="${category}"]`);
|
||||
const addBtn = section.querySelector(`[data-notif-add="${category}"]`);
|
||||
const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`);
|
||||
if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return;
|
||||
|
||||
keys.forEach(key => {
|
||||
const option = document.createElement('option');
|
||||
option.value = key;
|
||||
option.textContent = toHumanLabel(key);
|
||||
select.appendChild(option);
|
||||
});
|
||||
|
||||
if (keys.length) select.value = keys[0];
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
renderThresholdChips(category);
|
||||
renderAlertDescription(category);
|
||||
renderPerAlertToggle(category);
|
||||
});
|
||||
addBtn.addEventListener('click', () => addThresholdFromInput(category));
|
||||
input.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
addThresholdFromInput(category);
|
||||
}
|
||||
});
|
||||
|
||||
NOTIFICATION_PRESETS.forEach(preset => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = preset;
|
||||
btn.addEventListener('click', () => addThresholdValue(category, preset));
|
||||
presetsWrap.appendChild(btn);
|
||||
});
|
||||
|
||||
renderThresholdChips(category);
|
||||
renderAlertDescription(category);
|
||||
});
|
||||
|
||||
setNotificationTab('surge');
|
||||
|
||||
wireEnableToggles(section);
|
||||
|
||||
// Background: pull canonical registry from the bot, merge with fallback,
|
||||
// append any registry-only keys and refresh descriptions. Fallback stays
|
||||
// in use if the endpoint 404s (settings-site deployed ahead of bot) or
|
||||
// the fetch fails (network/proxy error).
|
||||
fetchAlertRegistry().then(registry => {
|
||||
if (!registry) return;
|
||||
applyMergedRegistry(section, registry);
|
||||
renderAllEnableUI(section);
|
||||
}).catch(() => {});
|
||||
|
||||
// Phase 9: pull enable state and paint the toggles. Runs in parallel with
|
||||
// the registry fetch above; renderAll is idempotent so ordering is OK.
|
||||
fetchEnableState().then(state => {
|
||||
if (!state) return;
|
||||
enableState = state;
|
||||
window.Notifications.state = state;
|
||||
renderAllEnableUI(section);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function keysForCategory(category) {
|
||||
return activeTabKeys[category] || [];
|
||||
}
|
||||
|
||||
function renderMasterCheckboxes(section) {
|
||||
section.querySelectorAll('[data-notif-master]').forEach(cb => {
|
||||
cb.checked = enableState.master === true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderCategoryCheckbox(section, category) {
|
||||
const cb = section.querySelector(`[data-notif-category-toggle="${category}"]`);
|
||||
if (!cb) return;
|
||||
const keys = keysForCategory(category);
|
||||
const allOn = keys.length > 0 && keys.every(k => enableState.perKey[k] === true);
|
||||
cb.checked = enableState.master === true && allOn;
|
||||
}
|
||||
|
||||
function renderPerAlertToggle(category) {
|
||||
const section = document.getElementById('s-notifications');
|
||||
if (!section) return;
|
||||
const panel = section.querySelector(`[data-notif-panel="${category}"]`);
|
||||
if (!panel) return;
|
||||
const alertCb = panel.querySelector('[data-notif-alert]');
|
||||
const label = panel.querySelector('[data-notif-alert-label]');
|
||||
const alertKey = getSelectedAlertKey(category);
|
||||
const on = alertKey ? enableState.perKey[alertKey] === true : false;
|
||||
if (alertCb) alertCb.checked = on;
|
||||
if (label) label.textContent = on ? 'Alert enabled' : 'Alert disabled';
|
||||
|
||||
// Disable the chip/input editor when master is off or this alert is off.
|
||||
const editor = panel.querySelector('.notif-editor');
|
||||
if (editor) {
|
||||
const disabled = !enableState.master || !on;
|
||||
editor.classList.toggle('notif-disabled', disabled);
|
||||
}
|
||||
}
|
||||
|
||||
function renderAllEnableUI(section) {
|
||||
renderMasterCheckboxes(section);
|
||||
Object.keys(activeTabKeys).forEach(cat => renderCategoryCheckbox(section, cat));
|
||||
Object.keys(activeTabKeys).forEach(cat => renderPerAlertToggle(cat));
|
||||
}
|
||||
|
||||
function wireEnableToggles(section) {
|
||||
// Master — multiple DOM copies; they share state via enableState and
|
||||
// re-render after each mutation, so clicking any one updates all.
|
||||
section.querySelectorAll('[data-notif-master]').forEach(cb => {
|
||||
cb.addEventListener('change', async () => {
|
||||
const prev = enableState.master;
|
||||
const next = cb.checked;
|
||||
try {
|
||||
const newState = await postToggle({ master: true, enabled: next });
|
||||
enableState = newState;
|
||||
window.Notifications.state = newState;
|
||||
renderAllEnableUI(section);
|
||||
} catch (e) {
|
||||
cb.checked = prev;
|
||||
renderAllEnableUI(section);
|
||||
Util.showToast('Failed to update master toggle.', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Per-category "All in category" toggles.
|
||||
section.querySelectorAll('[data-notif-category-toggle]').forEach(cb => {
|
||||
const category = cb.dataset.notifCategoryToggle;
|
||||
cb.addEventListener('change', async () => {
|
||||
const next = cb.checked;
|
||||
const prev = !next; // pre-toggle state, for revert on failure
|
||||
try {
|
||||
const newState = await postToggle({ category, enabled: next });
|
||||
enableState = newState;
|
||||
window.Notifications.state = newState;
|
||||
renderAllEnableUI(section);
|
||||
} catch (e) {
|
||||
cb.checked = prev;
|
||||
renderAllEnableUI(section);
|
||||
Util.showToast(`Failed to update ${category} category toggle.`, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Per-alert toggles — one per category panel, scoped to the currently
|
||||
// selected alertKey in that panel's dropdown.
|
||||
section.querySelectorAll('.notif-panel').forEach(panel => {
|
||||
const category = panel.dataset.notifPanel;
|
||||
const alertCb = panel.querySelector('[data-notif-alert]');
|
||||
if (!alertCb) return;
|
||||
alertCb.addEventListener('change', async () => {
|
||||
const alertKey = getSelectedAlertKey(category);
|
||||
if (!alertKey) {
|
||||
alertCb.checked = false;
|
||||
return;
|
||||
}
|
||||
const prev = enableState.perKey[alertKey] === true;
|
||||
const next = alertCb.checked;
|
||||
try {
|
||||
const newState = await postToggle({ key: alertKey, enabled: next });
|
||||
enableState = newState;
|
||||
window.Notifications.state = newState;
|
||||
renderAllEnableUI(section);
|
||||
} catch (e) {
|
||||
alertCb.checked = prev;
|
||||
renderAllEnableUI(section);
|
||||
Util.showToast(`Failed to update ${alertKey} toggle.`, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseNotificationThresholdsConfig(config) {
|
||||
const rawJson = config.NOTIFICATION_THRESHOLDS_JSON;
|
||||
if (rawJson && String(rawJson).trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawJson);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) {
|
||||
return config.NOTIFICATION_THRESHOLDS;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function serializeNotificationThresholds(obj) {
|
||||
const ordered = {};
|
||||
Object.keys(obj).sort().forEach(key => {
|
||||
const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : [];
|
||||
ordered[key] = arr;
|
||||
});
|
||||
return JSON.stringify(ordered);
|
||||
}
|
||||
|
||||
function setNotificationTab(category) {
|
||||
document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.notifTab === category);
|
||||
});
|
||||
document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => {
|
||||
panel.classList.toggle('hidden', panel.dataset.notifPanel !== category);
|
||||
});
|
||||
}
|
||||
|
||||
function addThresholdFromInput(category) {
|
||||
const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`);
|
||||
if (!input) return;
|
||||
const value = input.value.trim().toLowerCase();
|
||||
if (addThresholdValue(category, value)) input.value = '';
|
||||
}
|
||||
|
||||
function addThresholdValue(category, rawValue) {
|
||||
const value = String(rawValue || '').trim().toLowerCase();
|
||||
if (!isValidThresholdValue(value)) {
|
||||
Util.showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const alertKey = getSelectedAlertKey(category);
|
||||
if (!alertKey) return false;
|
||||
|
||||
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
|
||||
if (current.includes(value)) return false;
|
||||
current.push(value);
|
||||
notificationThresholdsState[alertKey] = current;
|
||||
syncNotificationThresholdsField();
|
||||
renderThresholdChips(category);
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeThresholdValue(category, valueToRemove) {
|
||||
const alertKey = getSelectedAlertKey(category);
|
||||
if (!alertKey) return;
|
||||
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
|
||||
notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove));
|
||||
syncNotificationThresholdsField();
|
||||
renderThresholdChips(category);
|
||||
}
|
||||
|
||||
function renderThresholdChips(category) {
|
||||
const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`);
|
||||
if (!chipsWrap) return;
|
||||
const alertKey = getSelectedAlertKey(category);
|
||||
const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey])
|
||||
? notificationThresholdsState[alertKey]
|
||||
: [];
|
||||
|
||||
chipsWrap.replaceChildren();
|
||||
thresholds.forEach(value => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'notif-chip';
|
||||
chip.textContent = value;
|
||||
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.title = `Remove ${value}`;
|
||||
remove.textContent = '×';
|
||||
remove.addEventListener('click', () => removeThresholdValue(category, value));
|
||||
|
||||
chip.appendChild(remove);
|
||||
chipsWrap.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAlertDescription(category) {
|
||||
const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`);
|
||||
if (!descriptionEl) return;
|
||||
const alertKey = getSelectedAlertKey(category);
|
||||
descriptionEl.textContent = activeAlertDescriptions[alertKey] || 'No description available for this alert key yet.';
|
||||
}
|
||||
|
||||
function syncNotificationThresholdsField() {
|
||||
const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]');
|
||||
if (!hiddenField) return;
|
||||
const serialized = serializeNotificationThresholds(notificationThresholdsState);
|
||||
hiddenField.value = serialized;
|
||||
Fields.markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized);
|
||||
hiddenField.classList.toggle('changed', Fields.isChanged('NOTIFICATION_THRESHOLDS_JSON'));
|
||||
}
|
||||
|
||||
function getSelectedAlertKey(category) {
|
||||
const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`);
|
||||
return select ? select.value : '';
|
||||
}
|
||||
|
||||
function isValidThresholdValue(value) {
|
||||
if (!value) return false;
|
||||
if (/^\d+$/.test(value)) return true;
|
||||
return /^(\d+[mhd])+$/.test(value);
|
||||
}
|
||||
|
||||
function toHumanLabel(key) {
|
||||
return String(key)
|
||||
.split('_')
|
||||
.map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3'
|
||||
? part.toUpperCase()
|
||||
: part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
window.Notifications = {
|
||||
initNotificationsEditor,
|
||||
isValidThresholdValue,
|
||||
toHumanLabel,
|
||||
fetchAlertRegistry,
|
||||
fetchEnableState,
|
||||
NOTIFICATION_PRESETS,
|
||||
FALLBACK_TAB_KEYS,
|
||||
FALLBACK_ALERT_DESCRIPTIONS,
|
||||
registry: null,
|
||||
state: enableState,
|
||||
get tabKeys() { return activeTabKeys; },
|
||||
get alertDescriptions() { return activeAlertDescriptions; }
|
||||
};
|
||||
})();
|
||||
@@ -10,7 +10,6 @@
|
||||
'/behavior': 's-behavior',
|
||||
'/threads': 's-threads',
|
||||
'/pins': 's-pins',
|
||||
'/notifications': 's-notifications',
|
||||
'/logging': 's-logging',
|
||||
'/automation': 's-automation',
|
||||
'/appearance': 's-appearance',
|
||||
|
||||
@@ -202,9 +202,6 @@ app.post('/api/config', apiLimiter, requireAuth, proxy('POST', '/config'));
|
||||
app.get('/api/discord/guild', apiLimiter, requireAuth, proxy('GET', '/discord/guild'));
|
||||
app.post('/api/restart', apiLimiter, requireAuth, proxy('POST', '/restart'));
|
||||
app.get('/api/restart/status', apiLimiter, requireAuth, proxy('GET', '/restart/status'));
|
||||
app.get('/api/notifications/alerts', apiLimiter, requireAuth, proxy('GET', '/notifications/alerts'));
|
||||
app.get('/api/notifications/state', apiLimiter, requireAuth, proxy('GET', '/notifications/state'));
|
||||
app.post('/api/notifications/toggle', apiLimiter, requireAuth, proxy('POST', '/notifications/toggle'));
|
||||
|
||||
app.get('/*splat', requireAuth, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
|
||||
263
tests/configSchema.test.js
Normal file
263
tests/configSchema.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ALLOWED_CONFIG_KEYS, getValidator } from '../services/configSchema.js';
|
||||
|
||||
describe('ALLOWED_CONFIG_KEYS', () => {
|
||||
it('is a non-empty Set', () => {
|
||||
expect(ALLOWED_CONFIG_KEYS).toBeInstanceOf(Set);
|
||||
expect(ALLOWED_CONFIG_KEYS.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes well-known runtime config keys', () => {
|
||||
for (const k of [
|
||||
'TICKET_CATEGORY_ID',
|
||||
'AUTO_CLOSE_ENABLED',
|
||||
'GMAIL_POLL_INTERVAL_SECONDS',
|
||||
'EMBED_COLOR_OPEN',
|
||||
'GAME_LIST'
|
||||
]) {
|
||||
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not contain stale removed keys', () => {
|
||||
for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) {
|
||||
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidator: type inference', () => {
|
||||
it('treats *_ENABLED as boolean', () => {
|
||||
const v = getValidator('AUTO_CLOSE_ENABLED');
|
||||
expect(v.type).toBe('boolean');
|
||||
});
|
||||
|
||||
it('treats *_ID as discord_id', () => {
|
||||
expect(getValidator('TICKET_CATEGORY_ID').type).toBe('discord_id');
|
||||
});
|
||||
|
||||
it('overrides ROLE_ID_TO_PING (mid-key _ID) as discord_id', () => {
|
||||
expect(getValidator('ROLE_ID_TO_PING').type).toBe('discord_id');
|
||||
});
|
||||
|
||||
it('treats *_HOURS / *_MINUTES / *_SECONDS as integer', () => {
|
||||
expect(getValidator('AUTO_CLOSE_AFTER_HOURS').type).toBe('integer');
|
||||
expect(getValidator('RATE_LIMIT_WINDOW_MINUTES').type).toBe('integer');
|
||||
expect(getValidator('GMAIL_POLL_INTERVAL_SECONDS').type).toBe('integer');
|
||||
});
|
||||
|
||||
it('treats *_COLOR as hex_color', () => {
|
||||
expect(getValidator('EMBED_COLOR_OPEN').type).toBe('hex_color');
|
||||
});
|
||||
|
||||
it('treats LOGO_URL as url', () => {
|
||||
expect(getValidator('LOGO_URL').type).toBe('url');
|
||||
});
|
||||
|
||||
it('treats *_EMAIL as email', () => {
|
||||
expect(getValidator('SUPPORT_EMAIL').type).toBe('email');
|
||||
});
|
||||
|
||||
it('falls back to string for unknown shapes', () => {
|
||||
expect(getValidator('TICKET_CATEGORY_NAME').type).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean validator', () => {
|
||||
const v = getValidator('AUTO_CLOSE_ENABLED');
|
||||
|
||||
it('accepts the literal true/false', () => {
|
||||
expect(v.validate(true)).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate(false)).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('accepts string "true"/"false"', () => {
|
||||
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('rejects garbage', () => {
|
||||
const res = v.validate('maybe');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/true or false/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integer validator', () => {
|
||||
const v = getValidator('AUTO_CLOSE_AFTER_HOURS');
|
||||
|
||||
it('coerces a numeric string to a number', () => {
|
||||
expect(v.validate('72')).toEqual({ ok: true, coerced: 72 });
|
||||
});
|
||||
|
||||
it('accepts zero', () => {
|
||||
expect(v.validate('0')).toEqual({ ok: true, coerced: 0 });
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
const res = v.validate('abc');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/whole number/);
|
||||
});
|
||||
|
||||
it('rejects floats', () => {
|
||||
expect(v.validate('1.5').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative integers', () => {
|
||||
expect(v.validate('-5').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty input as ok with empty coerced value', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
expect(v.validate(null)).toEqual({ ok: true, coerced: '' });
|
||||
expect(v.validate(undefined)).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hex_color validator', () => {
|
||||
const v = getValidator('EMBED_COLOR_OPEN');
|
||||
|
||||
it('accepts 0xRRGGBB form', () => {
|
||||
expect(v.validate('0xFF00AA')).toEqual({ ok: true, coerced: '0xFF00AA' });
|
||||
});
|
||||
|
||||
it('accepts #RRGGBB form and normalizes to 0xRRGGBB', () => {
|
||||
expect(v.validate('#ff00aa')).toEqual({ ok: true, coerced: '0xFF00AA' });
|
||||
});
|
||||
|
||||
it('accepts bare RRGGBB and normalizes', () => {
|
||||
expect(v.validate('00ff00')).toEqual({ ok: true, coerced: '0x00FF00' });
|
||||
});
|
||||
|
||||
it('rejects 3-digit shorthand', () => {
|
||||
expect(v.validate('#abc').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects garbage', () => {
|
||||
expect(v.validate('purple').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('url validator (LOGO_URL)', () => {
|
||||
const v = getValidator('LOGO_URL');
|
||||
|
||||
it('accepts a full URL', () => {
|
||||
expect(v.validate('https://example.com/logo.png')).toEqual({
|
||||
ok: true,
|
||||
coerced: 'https://example.com/logo.png'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bare hostnames', () => {
|
||||
expect(v.validate('example.com').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('discord_id validator', () => {
|
||||
const v = getValidator('TICKET_CATEGORY_ID');
|
||||
|
||||
it('accepts an 18-digit snowflake', () => {
|
||||
expect(v.validate('123456789012345678')).toEqual({
|
||||
ok: true,
|
||||
coerced: '123456789012345678'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a 20-digit snowflake', () => {
|
||||
const id = '12345678901234567890';
|
||||
expect(v.validate(id)).toEqual({ ok: true, coerced: id });
|
||||
});
|
||||
|
||||
it('rejects too-short IDs', () => {
|
||||
expect(v.validate('12345').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
expect(v.validate('not-an-id').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('discord_id_list validator', () => {
|
||||
// ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it
|
||||
// hits the string fallback. discord_id_list only fires for `*_IDS` keys, so
|
||||
// exercise it with a hypothetical name.
|
||||
const v = getValidator('STAFF_USER_IDS');
|
||||
|
||||
it('infers type discord_id_list for *_IDS keys', () => {
|
||||
expect(v.type).toBe('discord_id_list');
|
||||
});
|
||||
|
||||
it('accepts a single ID', () => {
|
||||
expect(v.validate('123456789012345678'))
|
||||
.toEqual({ ok: true, coerced: '123456789012345678' });
|
||||
});
|
||||
|
||||
it('accepts a comma-separated list and trims spaces', () => {
|
||||
expect(v.validate('123456789012345678, 987654321098765432'))
|
||||
.toEqual({ ok: true, coerced: '123456789012345678,987654321098765432' });
|
||||
});
|
||||
|
||||
it('rejects if any segment is not a snowflake', () => {
|
||||
const res = v.validate('123456789012345678,nope');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/not a Discord ID/);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('string validator (fallback)', () => {
|
||||
const v = getValidator('TICKET_CATEGORY_NAME');
|
||||
|
||||
it('coerces "true"/"false" to booleans (legacy)', () => {
|
||||
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('coerces numeric-looking strings to numbers (legacy)', () => {
|
||||
expect(v.validate('42')).toEqual({ ok: true, coerced: 42 });
|
||||
expect(v.validate('3.14')).toEqual({ ok: true, coerced: 3.14 });
|
||||
});
|
||||
|
||||
it('passes plain strings through', () => {
|
||||
expect(v.validate('Open Tickets')).toEqual({ ok: true, coerced: 'Open Tickets' });
|
||||
});
|
||||
|
||||
it('passes empty string through unchanged', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
|
||||
it('rejects null', () => {
|
||||
expect(v.validate(null).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('email validator', () => {
|
||||
const v = getValidator('SUPPORT_EMAIL');
|
||||
|
||||
it('accepts valid email', () => {
|
||||
expect(v.validate('support@example.com'))
|
||||
.toEqual({ ok: true, coerced: 'support@example.com' });
|
||||
});
|
||||
|
||||
it('rejects malformed strings', () => {
|
||||
expect(v.validate('not-an-email').ok).toBe(false);
|
||||
expect(v.validate('a@').ok).toBe(false);
|
||||
expect(v.validate('@b').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
241
tests/utils.test.js
Normal file
241
tests/utils.test.js
Normal file
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
extractRawEmail,
|
||||
escapeHtml,
|
||||
sanitizeEmbedText,
|
||||
truncateEmbedDescription,
|
||||
replaceVariables,
|
||||
getPriorityEmoji,
|
||||
safeEqual,
|
||||
isStaff
|
||||
} from '../utils.js';
|
||||
|
||||
describe('stripEmailQuotes', () => {
|
||||
it('strips "On X wrote:" reply quote', () => {
|
||||
const input = 'My reply.\nOn Mon, May 5, 2025 at 1:00 PM Bob <bob@x.com> wrote:\n> previous message';
|
||||
expect(stripEmailQuotes(input)).toBe('My reply.');
|
||||
});
|
||||
|
||||
it('strips "From: …" reply header block', () => {
|
||||
const input = 'New reply text.\nFrom: Bob <bob@x.com>\nSent: Monday\nSubject: Re: foo';
|
||||
expect(stripEmailQuotes(input)).toBe('New reply text.');
|
||||
});
|
||||
|
||||
it('strips "_____" signature underline', () => {
|
||||
const input = 'My message.\n_____\nold thread content';
|
||||
expect(stripEmailQuotes(input)).toBe('My message.');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripEmailQuotes('')).toBe('');
|
||||
});
|
||||
|
||||
it('trims whitespace when no marker is found', () => {
|
||||
expect(stripEmailQuotes(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('keeps body intact when "On" appears mid-text without "wrote:"', () => {
|
||||
expect(stripEmailQuotes('I clicked On the button.')).toBe('I clicked On the button.');
|
||||
});
|
||||
|
||||
it('normalizes CRLF before scanning', () => {
|
||||
const input = 'New reply.\r\nOn Monday Bob <b@x.com> wrote:\r\n> quoted';
|
||||
expect(stripEmailQuotes(input)).toBe('New reply.');
|
||||
});
|
||||
|
||||
it('picks earliest cutoff when multiple markers match', () => {
|
||||
// Earlier in the body: "On X wrote:". Later: "_____" underline.
|
||||
// The earliest cutoff is the reply marker, not the underline.
|
||||
const input = 'My new reply.\nOn Mon Bob wrote:\n> quoted text\n_____\nsignature';
|
||||
expect(stripEmailQuotes(input)).toBe('My new reply.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMobileFooter', () => {
|
||||
it('removes "Sent from my iPhone"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my iPhone').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Sent from my Android"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my Android').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Sent from my Galaxy"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my Galaxy').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Get Outlook for iOS"', () => {
|
||||
expect(stripMobileFooter('Hi\nGet Outlook for iOS').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('returns input unchanged when no footer present', () => {
|
||||
expect(stripMobileFooter('Just a normal message')).toBe('Just a normal message');
|
||||
});
|
||||
|
||||
it('returns null/undefined unchanged', () => {
|
||||
expect(stripMobileFooter(null)).toBe(null);
|
||||
expect(stripMobileFooter(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(stripMobileFooter('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRawEmail', () => {
|
||||
it('extracts address from "Name <email>" form', () => {
|
||||
expect(extractRawEmail('Bob <bob@example.com>')).toBe('bob@example.com');
|
||||
});
|
||||
|
||||
it('returns trimmed input when angle brackets absent', () => {
|
||||
expect(extractRawEmail(' bob@example.com ')).toBe('bob@example.com');
|
||||
});
|
||||
|
||||
it('handles quoted name', () => {
|
||||
expect(extractRawEmail('"Bob, the Developer" <bob@example.com>')).toBe('bob@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes <, >, &, ", \'', () => {
|
||||
expect(escapeHtml('<script>alert("xss")</script>'))
|
||||
.toBe('<script>alert("xss")</script>');
|
||||
expect(escapeHtml("a & b's <foo>")).toBe('a & b's <foo>');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('passes through plain text unchanged', () => {
|
||||
expect(escapeHtml('plain text')).toBe('plain text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeEmbedText', () => {
|
||||
it('replaces triple-backticks to prevent code-block escape', () => {
|
||||
expect(sanitizeEmbedText('```injected```')).toBe("'''injected'''");
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitizeEmbedText(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(sanitizeEmbedText(null)).toBe('');
|
||||
expect(sanitizeEmbedText(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateEmbedDescription', () => {
|
||||
it('returns short strings unchanged', () => {
|
||||
expect(truncateEmbedDescription('hi')).toBe('hi');
|
||||
});
|
||||
|
||||
it('truncates at default 4096 with ellipsis', () => {
|
||||
const big = 'a'.repeat(5000);
|
||||
const out = truncateEmbedDescription(big);
|
||||
expect(out.length).toBe(4096);
|
||||
expect(out.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('respects custom max', () => {
|
||||
expect(truncateEmbedDescription('abcdef', 5)).toBe('ab...');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(truncateEmbedDescription(null)).toBe('');
|
||||
expect(truncateEmbedDescription(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceVariables', () => {
|
||||
it('substitutes ticket fields', () => {
|
||||
const ctx = {
|
||||
ticket: {
|
||||
sender_name: 'Alice',
|
||||
senderEmail: 'alice@x.com',
|
||||
ticketNumber: 42,
|
||||
subject: 'Help'
|
||||
}
|
||||
};
|
||||
const out = replaceVariables('User {ticket.user} ({ticket.email}) #{ticket.number} - {ticket.subject}', ctx);
|
||||
expect(out).toBe('User Alice (alice@x.com) #42 - Help');
|
||||
});
|
||||
|
||||
it('falls back when fields missing', () => {
|
||||
const out = replaceVariables('{ticket.user} {ticket.email} {ticket.subject}', { ticket: {} });
|
||||
expect(out).toBe('Unknown No subject');
|
||||
});
|
||||
|
||||
it('substitutes staff fields', () => {
|
||||
const ctx = {
|
||||
staff: { username: 'bob', displayName: 'Bob the Builder', mention: '<@123>' }
|
||||
};
|
||||
expect(replaceVariables('{staff.user} / {staff.name} / {staff.mention}', ctx))
|
||||
.toBe('bob / Bob the Builder / <@123>');
|
||||
});
|
||||
|
||||
it('returns empty string for empty template', () => {
|
||||
expect(replaceVariables('')).toBe('');
|
||||
expect(replaceVariables(null)).toBe('');
|
||||
});
|
||||
|
||||
it('substitutes hours when provided', () => {
|
||||
expect(replaceVariables('after {hours} hours', { hours: 24 })).toBe('after 24 hours');
|
||||
});
|
||||
|
||||
it('substitutes {date} and {time} from current time', () => {
|
||||
const out = replaceVariables('on {date}', {});
|
||||
expect(out).toMatch(/^on \S+/);
|
||||
expect(out).not.toContain('{date}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriorityEmoji', () => {
|
||||
it('maps high/medium/low/normal to CONFIG values', () => {
|
||||
expect(typeof getPriorityEmoji('high')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('low')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('medium')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('normal')).toBe('string');
|
||||
});
|
||||
|
||||
it('falls back for unknown priority', () => {
|
||||
expect(typeof getPriorityEmoji('weird')).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeEqual', () => {
|
||||
it('returns true for matching strings', () => {
|
||||
expect(safeEqual('hello', 'hello')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for mismatched strings', () => {
|
||||
expect(safeEqual('hello', 'world')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for length mismatch (no throw)', () => {
|
||||
expect(safeEqual('a', 'abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined inputs', () => {
|
||||
expect(safeEqual(null, 'abc')).toBe(false);
|
||||
expect(safeEqual(undefined, undefined)).toBe(true);
|
||||
expect(safeEqual('', '')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStaff', () => {
|
||||
it('returns false for null/undefined member', () => {
|
||||
expect(isStaff(null)).toBe(false);
|
||||
expect(isStaff(undefined)).toBe(false);
|
||||
expect(isStaff({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for member with no roles cache', () => {
|
||||
expect(isStaff({ roles: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
140
utils.js
140
utils.js
@@ -112,22 +112,29 @@ function getCleanBody(payload) {
|
||||
function stripEmailQuotes(text) {
|
||||
let cleaned = text.replace(/\r\n/g, '\n');
|
||||
|
||||
// Pick the earliest match across all markers, not just the first marker that
|
||||
// matches anywhere. The previous order-dependent loop could truncate at a
|
||||
// late "_____" signature underline even when an earlier "On X wrote:" reply
|
||||
// header was the real cutoff.
|
||||
const markers = [
|
||||
/\n_{5,}\s*$/m,
|
||||
/\nOn .* wrote:/i,
|
||||
/\nFrom:\s.*<.*@.*>/i,
|
||||
/\nSent:\s.*$/i,
|
||||
/\nTo:\s.*$/i,
|
||||
/\nSubject:\s.*$/i,
|
||||
/\nOn .* wrote:/i
|
||||
/\n_{5,}\s*$/m
|
||||
];
|
||||
|
||||
let earliest = -1;
|
||||
for (const m of markers) {
|
||||
const match = cleaned.match(m);
|
||||
if (match) {
|
||||
cleaned = cleaned.substring(0, match.index);
|
||||
break;
|
||||
if (match && (earliest === -1 || match.index < earliest)) {
|
||||
earliest = match.index;
|
||||
}
|
||||
}
|
||||
if (earliest !== -1) {
|
||||
cleaned = cleaned.substring(0, earliest);
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
@@ -173,31 +180,6 @@ function extractRawEmail(headerValue) {
|
||||
return match ? match[1].trim() : headerValue.trim();
|
||||
}
|
||||
|
||||
// --- DATE ---
|
||||
|
||||
const getFormattedDate = () => {
|
||||
const now = new Date();
|
||||
const datePart = now
|
||||
.toLocaleDateString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
.replace(/\//g, '-');
|
||||
const timePart = now.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
const tzPart = new Intl.DateTimeFormat('en-US', {
|
||||
timeZoneName: 'short'
|
||||
})
|
||||
.formatToParts(now)
|
||||
.find(p => p.type === 'timeZoneName').value;
|
||||
return `${datePart} ${timePart} ${tzPart}`;
|
||||
};
|
||||
|
||||
// --- GAME DETECTION ---
|
||||
// Map<lowercase-alias, { canonical, re }> built once at module load so detectGame
|
||||
// doesn't allocate a fresh RegExp per game/alias per call.
|
||||
@@ -233,16 +215,6 @@ function getPriorityEmoji(priority) {
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityColor(priority) {
|
||||
switch (priority) {
|
||||
case 'high': return 0xFF0000;
|
||||
case 'low': return 0x00FF00;
|
||||
case 'normal':
|
||||
case 'medium':
|
||||
default: return CONFIG.EMBED_COLOR_INFO;
|
||||
}
|
||||
}
|
||||
|
||||
// --- TEMPLATE VARIABLES ---
|
||||
|
||||
function replaceVariables(template, context = {}) {
|
||||
@@ -292,13 +264,6 @@ function sanitizeEmbedText(str) {
|
||||
|
||||
// --- EMBED TRUNCATION ---
|
||||
|
||||
/** Truncate a string for use as an embed field value (max 1024). */
|
||||
function truncateEmbedField(str, max = 1024) {
|
||||
if (str == null) return '';
|
||||
const s = String(str);
|
||||
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
||||
}
|
||||
|
||||
/** Truncate a string for use as an embed description (max 4096). */
|
||||
function truncateEmbedDescription(str, max = 4096) {
|
||||
if (str == null) return '';
|
||||
@@ -306,99 +271,18 @@ function truncateEmbedDescription(str, max = 4096) {
|
||||
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the 6 000 char total embed limit across an array of EmbedBuilder
|
||||
* instances. Mutates in place: trims the largest description first, then
|
||||
* largest field values, until the total is under 6 000 chars.
|
||||
* Returns the same array for chaining.
|
||||
*/
|
||||
function enforceEmbedLimit(embeds) {
|
||||
const charCount = (e) => {
|
||||
const d = e.data || {};
|
||||
let total = 0;
|
||||
if (d.title) total += d.title.length;
|
||||
if (d.description) total += d.description.length;
|
||||
if (d.footer?.text) total += d.footer.text.length;
|
||||
if (d.author?.name) total += d.author.name.length;
|
||||
if (d.fields) {
|
||||
for (const f of d.fields) {
|
||||
if (f.name) total += f.name.length;
|
||||
if (f.value) total += f.value.length;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const LIMIT = 6000;
|
||||
|
||||
const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0);
|
||||
|
||||
// Trim largest descriptions first
|
||||
while (totalChars() > LIMIT) {
|
||||
let largestIdx = -1;
|
||||
let largestLen = 0;
|
||||
for (let i = 0; i < embeds.length; i++) {
|
||||
const desc = embeds[i].data?.description;
|
||||
if (desc && desc.length > largestLen) {
|
||||
largestLen = desc.length;
|
||||
largestIdx = i;
|
||||
}
|
||||
}
|
||||
if (largestIdx === -1 || largestLen <= 4) break;
|
||||
const excess = totalChars() - LIMIT;
|
||||
const newLen = Math.max(1, largestLen - excess - 3);
|
||||
embeds[largestIdx].setDescription(
|
||||
embeds[largestIdx].data.description.slice(0, newLen) + '...'
|
||||
);
|
||||
if (totalChars() <= LIMIT) break;
|
||||
// If still over, loop will pick next largest
|
||||
}
|
||||
|
||||
// Trim largest field values
|
||||
while (totalChars() > LIMIT) {
|
||||
let targetEmbed = null;
|
||||
let targetFieldIdx = -1;
|
||||
let targetLen = 0;
|
||||
for (const e of embeds) {
|
||||
const fields = e.data?.fields || [];
|
||||
for (let fi = 0; fi < fields.length; fi++) {
|
||||
if (fields[fi].value && fields[fi].value.length > targetLen) {
|
||||
targetLen = fields[fi].value.length;
|
||||
targetEmbed = e;
|
||||
targetFieldIdx = fi;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!targetEmbed || targetLen <= 4) break;
|
||||
const excess = totalChars() - LIMIT;
|
||||
const newLen = Math.max(1, targetLen - excess - 3);
|
||||
targetEmbed.data.fields[targetFieldIdx].value =
|
||||
targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...';
|
||||
}
|
||||
|
||||
return embeds;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeEmbedText,
|
||||
truncateEmbedField,
|
||||
truncateEmbedDescription,
|
||||
enforceEmbedLimit,
|
||||
BLOCK_TAG_REGEX,
|
||||
escapeRegex,
|
||||
escapeHtml,
|
||||
safeEqual,
|
||||
isStaff,
|
||||
decodeHtmlEntities,
|
||||
htmlToTextWithBlocks,
|
||||
decodeGmailData,
|
||||
getCleanBody,
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
extractRawEmail,
|
||||
getFormattedDate,
|
||||
detectGame,
|
||||
getPriorityEmoji,
|
||||
getPriorityColor,
|
||||
replaceVariables
|
||||
};
|
||||
|
||||
@@ -41,7 +41,10 @@ async function renameChannel(channelId, newName) {
|
||||
if (res.status === 429) {
|
||||
const retryAfterSec = (body && typeof body === 'object' && body.retry_after) || null;
|
||||
const retryAfterMs = retryAfterSec != null ? Math.ceil(Number(retryAfterSec) * 1000) : null;
|
||||
logWarn('renamer', `429 rename channel=${channelId} retry_after=${retryAfterSec}`).catch(() => {});
|
||||
// Local log only; the channelQueue fallback path handles recovery
|
||||
// transparently via discord.js's built-in 429 retry. Posting these to
|
||||
// the debug channel was non-actionable noise.
|
||||
console.warn(`[renamer] 429 rename channel=${channelId} retry_after=${retryAfterSec}`);
|
||||
|
||||
// Respect retry_after up to 2000ms; otherwise fail over immediately.
|
||||
if (retryAfterMs != null && retryAfterMs > 0 && retryAfterMs <= 2000) {
|
||||
|
||||
10
vitest.config.mjs
Normal file
10
vitest.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.js'],
|
||||
globals: false,
|
||||
testTimeout: 10000
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user