Dynamic overflow categories
This commit is contained in:
@@ -16,9 +16,10 @@ TICKET_CATEGORY_ID= # Category for email-originated ticket channel
|
|||||||
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
||||||
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
||||||
|
|
||||||
# Overflow categories when main hits 50 channels (comma-separated, optional)
|
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
|
||||||
# EMAIL_TICKET_OVERFLOW_CATEGORY_IDS=
|
TICKET_CATEGORY_NAME=Open Tickets
|
||||||
# DISCORD_TICKET_OVERFLOW_CATEGORY_IDS=
|
TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
|
||||||
|
TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
|
||||||
|
|
||||||
# Escalation categories (tier 2 and tier 3)
|
# Escalation categories (tier 2 and tier 3)
|
||||||
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
|
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
|
||||||
|
|||||||
@@ -12,22 +12,23 @@ DISCORD_GUILD_ID= # Test server ID
|
|||||||
|
|
||||||
# --- Discord: Channel & category IDs (test server) ---
|
# --- Discord: Channel & category IDs (test server) ---
|
||||||
# Ticket creation: set one or both; /panel and /email-routing choose behavior
|
# Ticket creation: set one or both; /panel and /email-routing choose behavior
|
||||||
DISCORD_TICKET_CATEGORY_ID=
|
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels (test)
|
||||||
TICKET_CATEGORY_ID=
|
TICKET_CATEGORY_ID= # Category for email-originated ticket channels (test)
|
||||||
# DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
||||||
# EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
||||||
|
|
||||||
# Overflow categories when main hits 50 channels (comma-separated, optional)
|
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
|
||||||
# EMAIL_TICKET_OVERFLOW_CATEGORY_IDS=
|
TICKET_CATEGORY_NAME=Open Tickets
|
||||||
# DISCORD_TICKET_OVERFLOW_CATEGORY_IDS=
|
TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
|
||||||
|
TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
|
||||||
|
|
||||||
# Escalation (optional for test)
|
# Escalation categories (tier 2 and tier 3; optional for minimal test)
|
||||||
# DISCORD_ESCALATED_CATEGORY_ID=
|
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
|
||||||
# EMAIL_ESCALATED_CATEGORY_ID= # legacy alias: ESCALATED_CATEGORY_ID
|
EMAIL_ESCALATED_CATEGORY_ID= # Fallback escalation category (email); legacy alias: ESCALATED_CATEGORY_ID
|
||||||
DISCORD_ESCALATED2_CHANNEL_ID=
|
DISCORD_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category/channel (Discord)
|
||||||
DISCORD_ESCALATED3_CHANNEL_ID=
|
DISCORD_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category/channel (Discord)
|
||||||
EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 category ID (email); env name *_CHANNEL_* is legacy
|
EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category ID (email); env name *_CHANNEL_* is legacy
|
||||||
EMAIL_ESCALATED3_CHANNEL_ID=
|
EMAIL_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category ID (email)
|
||||||
|
|
||||||
# --- Logging, transcripts, and utility ---
|
# --- Logging, transcripts, and utility ---
|
||||||
ROLE_ID_TO_PING=
|
ROLE_ID_TO_PING=
|
||||||
|
|||||||
779
README.md
779
README.md
@@ -1,39 +1,33 @@
|
|||||||
# Broccolini Bot
|
# Broccolini Bot
|
||||||
|
|
||||||
A Node.js support-ticket bot that connects **Gmail**, **Discord**, and **MongoDB** into a unified ticketing system. Incoming support emails become Discord ticket channels; staff replies in Discord are sent back to the sender via Gmail. All ticket state is persisted in MongoDB.
|
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.
|
||||||
|
|
||||||
Built for game-server hosting support (Indifferent Broccoli), with game detection from email content, configurable automation (auto-close, reminders, auto-unclaim), and a full set of Discord slash commands, buttons, modals, and context menus.
|
Built for game-server hosting support (Indifferent Broccoli), with game detection from email content, tiered escalation, optional staff “mirror” channels per claimer, saved responses, `/tag` categorization, and automation (auto-close, reminders, auto-unclaim).
|
||||||
|
|
||||||
**Quick links:** [Installation](#installation) · [Configuration](#configuration) · [Discord Commands](#discord-commands) · [Documentation](#documentation)
|
**Jump to:** [Features](#features) · [Quick start](#quick-start) · [Configuration](#configuration) · [Staff categories](#staff-personal-categories--mirror-channels) · [Commands](#discord-commands) · [Project layout](#project-structure)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Prerequisites](#prerequisites)
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Quick start](#quick-start)
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Configuration](#configuration)
|
- [Configuration](#configuration)
|
||||||
- [Discord](#discord)
|
- [Staff personal categories & mirror channels](#staff-personal-categories--mirror-channels)
|
||||||
- [Google OAuth2 / Gmail](#google-oauth2--gmail)
|
- [Running the bot](#running-the-bot-test-and-docker)
|
||||||
- [MongoDB](#mongodb)
|
- [Discord commands](#discord-commands)
|
||||||
- [Branding & Messages](#branding--messages)
|
- [Ticket UI (buttons & modals)](#ticket-ui-buttons--modals)
|
||||||
- [Automation](#automation)
|
- [Tag & response system](#tag--response-system)
|
||||||
- [Ticket Limits & Permissions](#ticket-limits--permissions)
|
- [Panel system](#panel-system)
|
||||||
- [Priority Levels](#priority-levels)
|
- [Channel renames & moves (rate limits)](#channel-renames--moves-rate-limits)
|
||||||
- [Claiming Options](#claiming-options)
|
- [Project structure](#project-structure)
|
||||||
- [Button & Embed Customization](#button--embed-customization)
|
- [Database collections](#database-collections)
|
||||||
- [Running the Bot](#running-the-bot)
|
- [HTTP: healthcheck & bOSScord API](#http-healthcheck--bosscord-api)
|
||||||
- [Test Environment](#test-environment)
|
- [Gmail OAuth refresh token](#gmail-oauth-refresh-token)
|
||||||
- [Discord Commands](#discord-commands)
|
- [Documentation in `docs/`](#documentation-in-docs)
|
||||||
- [Tag System](#tag-system)
|
|
||||||
- [Panel System](#panel-system)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Database Schema](#database-schema)
|
|
||||||
- [API Integrations](#api-integrations)
|
|
||||||
- [Healthcheck](#healthcheck)
|
|
||||||
- [Documentation](#documentation)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
- [Troubleshooting](#troubleshooting)
|
||||||
- [References](#references)
|
- [References](#references)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
@@ -42,532 +36,425 @@ Built for game-server hosting support (Indifferent Broccoli), with game detectio
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Email-to-Discord Ticketing
|
### Email → Discord
|
||||||
- Polls Gmail every 30 seconds for unread emails in the primary inbox
|
|
||||||
- Creates a dedicated Discord channel per ticket (`ticket-{sender}-{number}`)
|
|
||||||
- Detects the game from the email subject/body and tags the ticket accordingly
|
|
||||||
- Sends a rich embed with ticket metadata and action buttons (Claim, Close)
|
|
||||||
|
|
||||||
### Discord-to-Email Replies
|
- Polls Gmail about every **30 seconds** for new mail.
|
||||||
- Staff messages in a ticket channel are forwarded to the original sender via Gmail
|
- Creates a **Discord text channel** per email ticket (with overflow category support when a category is full).
|
||||||
- Replies are threaded in Gmail so the sender sees a continuous conversation
|
- Detects **game** from subject/body using `GAME_LIST`.
|
||||||
|
- Posts welcome + action **buttons** (Close, Claim, Escalate / De-escalate where applicable).
|
||||||
|
|
||||||
### Ticket Management
|
### Discord → Gmail
|
||||||
- **Claim / Unclaim** -- Staff can claim tickets; optional auto-unclaim after inactivity
|
|
||||||
- **Priority Levels** -- Low, Normal, High with color-coded embeds
|
|
||||||
- **Escalation** -- Move urgent tickets to a dedicated escalation category
|
|
||||||
- **Transfer / Move** -- Reassign tickets between staff or categories
|
|
||||||
- **Close Confirmation** -- Prevents accidental closes with a confirmation prompt
|
|
||||||
- **Transcripts** -- Full conversation transcripts posted to a dedicated channel on close
|
|
||||||
- **Auto-Close** -- Automatically close tickets after configurable hours of inactivity
|
|
||||||
- **Inactivity Reminders** -- Notify the channel when a ticket goes stale
|
|
||||||
|
|
||||||
### Panel System
|
- For **email-sourced** tickets, staff messages in the ticket channel are **forwarded** to the customer via Gmail (threaded).
|
||||||
- Deploy a "Open Ticket" button panel to any channel with `/panel`
|
- **Discord-only** tickets (`gmailThreadId` prefix `discord-` / `discord-msg-`) do not use Gmail for replies; conversation stays in Discord.
|
||||||
- Users click the button, fill out a modal form, and a ticket is created
|
|
||||||
|
|
||||||
### Tag System (Saved Responses)
|
### Ticket management
|
||||||
- Set ticket category with `/tag` (dropdown); create reusable response templates with `/response create`
|
|
||||||
- Dynamic template variables: `{ticket.user}`, `{staff.name}`, `{server.name}`, `{date}`, etc.
|
|
||||||
- Autocomplete-enabled `/tag` command for instant use
|
|
||||||
|
|
||||||
### Account Info Lookup
|
- **Claim / Unclaim** via buttons (not slash commands); optional claim overwrite and auto-unclaim.
|
||||||
- `/accountinfo` searches website users by email or Discord ID
|
- **Priority** (`low` / `normal` / `medium` / `high`) with configurable emojis and `/priority`.
|
||||||
- Results show linked servers, game details, and user metadata
|
- **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).
|
||||||
|
|
||||||
### Analytics & Logging
|
### Staff personal categories (optional)
|
||||||
- In-memory tracking of command usage, button clicks, and errors
|
|
||||||
- `/stats` shows uptime, interaction counts, and error rate
|
- Per-staff Discord **category map** so when someone **claims**, the main ticket channel can move into their category and a **mirror channel** can be created with a pinned embed linking back to the real ticket.
|
||||||
- Configurable logging channel for ticket lifecycle events
|
- When a **non-claimer** posts in the ticket channel, the mirror channel can be **pinged** with a quote and jump link; optional **DM** via `/notifydm`.
|
||||||
|
- Unclaim and close clean up mirror channels when configured.
|
||||||
|
|
||||||
|
See [Staff personal categories](#staff-personal-categories--mirror-channels).
|
||||||
|
|
||||||
|
### 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` for the bOSScord cockpit when `BOSSCORD_API_KEY` is set.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ BROCCOLINI BOT │
|
│ BROCCOLINI BOT │
|
||||||
├────────────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
│ │
|
│ Gmail (inbox) ──► gmail-poll.js ──► Discord ticket channels │
|
||||||
│ ┌───────────┐ ┌────────────────┐ ┌──────────────────┐ │
|
│ │ ▲ │
|
||||||
│ │ Gmail │─────>│ gmail-poll.js │─────>│ Discord │ │
|
│ ▼ │ │
|
||||||
│ │ (inbox) │ │ (every 30s) │ │ (ticket channel)│ │
|
│ services/gmail.js ◄── handlers/messages.js │
|
||||||
│ └───────────┘ └───────┬────────┘ └───────▲──────────┘ │
|
│ services/tickets.js handlers/buttons.js │
|
||||||
│ │ │ │
|
│ services/channelQueue.js handlers/commands.js│
|
||||||
│ v │ │
|
│ services/staffChannel.js │
|
||||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
│ │ │
|
||||||
│ │ services/ │ │ handlers/ │ │
|
│ ▼ │
|
||||||
│ │ gmail.js │<────>│ messages.js │ │
|
│ MongoDB (Mongoose) ◄── models.js │
|
||||||
│ │ tickets.js │ │ buttons.js │ │
|
│ │
|
||||||
│ │ guildSettings │ │ commands.js │ │
|
│ Express: GET / → "Active" ; optional /api → routes/bosscord.js │
|
||||||
│ └───────┬────────┘ └──────────────────┘ │
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
│ │ │
|
|
||||||
│ v │
|
|
||||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ MongoDB │ │ Express │ │
|
|
||||||
│ │ (Mongoose) │ │ (healthcheck) │ │
|
|
||||||
│ └────────────────┘ └──────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ Events: │
|
|
||||||
│ ready → Connect DB, register commands, start jobs │
|
|
||||||
│ interactionCreate → Buttons, slash commands, modals, menus │
|
|
||||||
│ messageCreate → Discord replies → Gmail │
|
|
||||||
└────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ticket lifecycle:**
|
**Typical email ticket lifecycle**
|
||||||
|
|
||||||
1. **Inbound email** -- Gmail poll detects a new unread message, creates a Discord channel and a MongoDB record.
|
1. New unread mail → poll creates Discord channel + `Ticket` document.
|
||||||
2. **Staff reply** -- A message in the Discord ticket channel is forwarded to the sender via Gmail.
|
2. Staff reply in channel → message handler sends Gmail reply (email tickets only).
|
||||||
3. **Close** -- A transcript is generated, a closure email is sent, and the Discord channel is deleted.
|
3. Close confirmed → transcript, optional closure email, channel delete; DB marked closed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
| Requirement | Version |
|
| Requirement | Notes |
|
||||||
|-------------|---------|
|
|-------------|--------|
|
||||||
| Node.js | ≥ 18.x |
|
| **Node.js** | **18+** recommended (Dockerfile uses 20). |
|
||||||
| npm | ≥ 9.x |
|
| **npm** | Install dependencies with `npm install`. |
|
||||||
| MongoDB | ≥ 5.x (Atlas or self-hosted) |
|
| **MongoDB** | Atlas or self-hosted; connection string in `MONGODB_URI`. |
|
||||||
|
| **Discord application** | Bot token, application ID, privileged intents: **Message Content**, **Server Members**; also Guilds + Guild Messages. |
|
||||||
|
| **Google Cloud** | Gmail API enabled; OAuth2 client ID/secret + refresh token for the support mailbox. |
|
||||||
|
|
||||||
You will also need:
|
|
||||||
|
|
||||||
- A **Discord bot** with the following intents enabled: Guilds, Guild Messages, Message Content, Guild Members
|
|
||||||
- A **Google Cloud project** with the Gmail API enabled and OAuth2 credentials (Client ID, Client Secret, Refresh Token)
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Quick start
|
||||||
|
|
||||||
Single-level repo: all commands run from the repo root. Create `.env` in the repo root (copy from `.env.example`).
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <your-repo-url>
|
git clone <your-repo-url>
|
||||||
cd broccolini-bot
|
cd broccolini-bot
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env with your Discord, Gmail, and MongoDB credentials (see Configuration).
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Restart after **any** `.env` change. After changing **slash command definitions**, restart so **`registerCommands()`** re-registers with Discord.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Same as quick start. Optional:
|
||||||
|
|
||||||
|
- **Test env:** copy `.env.test.example` → `.env.test`, run `npm run start:test` (sets `ENV_FILE`).
|
||||||
|
- **1Password CLI:** `npm run start:1p` / `start:test:1p` inject secrets (see `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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Create a `.env` file in the repo root (same directory as this README). All configuration is loaded via environment variables.
|
Configuration is **environment variables** only, loaded in [`config.js`](config.js) as `CONFIG`. Discord env names in tables below match `.env.example`.
|
||||||
|
|
||||||
> **Important:** After changing `.env`, you must **restart the process** (`npm start` / `node broccolini-discord.js`) for new values to take effect. If you add or change **slash commands** (e.g. `/escalate`, `/email-routing`, `/panel` options), restart the bot so it can **re-register** commands with Discord; otherwise new or updated commands may not appear.
|
### Discord (core)
|
||||||
|
|
||||||
> **Agent rule:** Changes to `.env` by an AI/agent must **require explicit user confirmation**. Prefer proposing changes to `.env.test` first and migrating to `.env` only after the user approves. See [ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md).
|
|
||||||
|
|
||||||
### Discord
|
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|---|---|---|
|
|----------|----------|-------------|
|
||||||
| `DISCORD_TOKEN` | Yes | Bot token from the Discord Developer Portal |
|
| `DISCORD_TOKEN` | Yes* | Bot token (*or `DISCORD_BOT_TOKEN`, first non-empty wins after trim). |
|
||||||
| `DISCORD_GUILD_ID` | Yes | Server (guild) ID where the bot operates |
|
| `DISCORD_APPLICATION_ID` | Yes | Used as `CLIENT_ID` for REST command registration. |
|
||||||
| `DISCORD_APPLICATION_ID` | Yes | Application ID for registering slash commands |
|
| `DISCORD_GUILD_ID` | Yes | Guild where slash commands are registered. |
|
||||||
| `TICKET_CATEGORY_ID` | Yes | Channel category ID where email ticket channels are created |
|
| `TICKET_CATEGORY_ID` | Yes | Default category for **email** tickets (startup also validates this). |
|
||||||
| `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated category IDs; used when main email category has 50 channels |
|
| `DISCORD_TICKET_CATEGORY_ID` | No | Category for **Discord** panel/context tickets (falls back to `TICKET_CATEGORY_ID`). |
|
||||||
| `DISCORD_TICKET_CATEGORY_ID` | No | Category for Discord panel tickets (defaults to `TICKET_CATEGORY_ID`) |
|
| `EMAIL_THREAD_CHANNEL_ID` / `DISCORD_THREAD_CHANNEL_ID` | No | Parent **text** channels for thread-style tickets, if you use threads. |
|
||||||
| `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated category IDs; used when main Discord ticket category has 50 channels |
|
| `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated extra categories when the main is full (50 channels). |
|
||||||
| `ROLE_ID_TO_PING` | Yes | Role ID to ping when a new ticket arrives |
|
| `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS` | No | Same for Discord ticket category. |
|
||||||
| `TRANSCRIPT_CHANNEL_ID` | No | Channel ID for posting ticket transcripts |
|
| `ROLE_ID_TO_PING` | Yes | Support role pinged on new tickets; aliases include `ROLE_TO_PING_ID` in code paths. |
|
||||||
| `LOGGING_CHANNEL_ID` | No | Channel ID for lifecycle log messages |
|
| `ADDITIONAL_STAFF_ROLES` | No | Extra role IDs treated as staff for commands. |
|
||||||
| `DEBUGGING_CHANNEL_ID` | No | Channel ID for error logs (escalate, deescalate, email-routing, Gmail poll, etc.) |
|
| `BLACKLISTED_ROLES` | No | Roles blocked from opening tickets. |
|
||||||
| `BACKUP_EXPORT_CHANNEL_ID` | No | Channel ID where `/backup` and `/export` post ticket dump files |
|
| `TRANSCRIPT_CHANNEL_ID` | No | Where transcripts are posted on close. |
|
||||||
| `ACCOUNT_INFO_CHANNEL_ID` | No | Channel ID for account info lookups (and `/accountinfo` visibility) |
|
| `LOGGING_CHANNEL_ID` | No | Ticket lifecycle logs. |
|
||||||
| `EMAIL_ESCALATED_CATEGORY_ID` | No | Category ID for escalated email tickets (tier 2+) |
|
| `DEBUGGING_CHANNEL_ID` | No | Optional error/debug forwarding. |
|
||||||
| `DISCORD_ESCALATED_CATEGORY_ID` | No | Category ID for escalated Discord-origin tickets |
|
| `BACKUP_EXPORT_CHANNEL_ID` | No | Target for `/backup` and `/export`. |
|
||||||
| `ESCALATION_MESSAGE` | No | Message sent when a ticket is escalated (supports `{support_name}`) |
|
| `ACCOUNT_INFO_CHANNEL_ID` | No | Account info flows. |
|
||||||
|
|
||||||
### Google OAuth2 / Gmail
|
### 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 personal categories & mirror channels
|
||||||
|
|
||||||
|
Optional organizational layer: **main ticket channel** stays where customers and staff talk; **mirror channel** is per-staffer category for notes + pings.
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `STAFF_CATEGORIES` | Map: `discordUserId:categoryId,discordUserId2:categoryId2`. If a claimer has **no** entry, **no** mirror channel is created (silent skip). |
|
||||||
|
| `STAFF_T1_CATEGORY` | Category for **unclaimed** “normal” tickets after unclaim (rename uses 🟢 prefix when rename limits allow). |
|
||||||
|
| `STAFF_T2_CATEGORY` | Pool category for **unclaimed tier-2** escalated tickets (used when moving after escalation / de-escalation flows). |
|
||||||
|
| `STAFF_T3_CATEGORY` | Pool category for **unclaimed tier-3** escalated tickets. |
|
||||||
|
| `UNCLAIMED_CATEGORY_ID` | Reserved in config for future/general fallback (currently not wired in code beyond `CONFIG`). |
|
||||||
|
|
||||||
|
If any of `STAFF_T1_CATEGORY` / `STAFF_T2_CATEGORY` / `STAFF_T3_CATEGORY` is unset, the corresponding **move is skipped** (no error).
|
||||||
|
|
||||||
|
### Google / Gmail
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|---|---|---|
|
|----------|----------|-------------|
|
||||||
| `GOOGLE_CLIENT_ID` | Yes | OAuth2 Client ID from Google Cloud Console |
|
| `GOOGLE_CLIENT_ID` | Yes | OAuth2 client ID. |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Yes | OAuth2 Client Secret |
|
| `GOOGLE_CLIENT_SECRET` | Yes | OAuth2 secret. |
|
||||||
| `REFRESH_TOKEN` | Yes | OAuth2 Refresh Token for the support inbox |
|
| `REFRESH_TOKEN` | Yes | Long-lived refresh for the inbox account. |
|
||||||
| `MY_EMAIL` | Yes | The support email address (e.g. `support@example.com`) |
|
| `MY_EMAIL` | Yes | Canonical support address (lowercased in config). |
|
||||||
|
|
||||||
### MongoDB
|
### MongoDB
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required |
|
||||||
|---|---|---|
|
|----------|----------|
|
||||||
| `MONGODB_URI` | Yes | MongoDB connection string (e.g. `mongodb+srv://user:pass@cluster/dbname`) |
|
| `MONGODB_URI` | Yes |
|
||||||
|
|
||||||
### Branding & Messages
|
Test: `npm run test-mongodb` (or with `ENV_FILE=.env.test`).
|
||||||
|
|
||||||
|
### Server & optional API
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|----------|---------|-------------|
|
||||||
| `SUPPORT_NAME` | -- | Display name for the support system |
|
| `DISCORD_ONLY_PORT` | `5000` | Express listen port (`CONFIG.PORT`). |
|
||||||
| `LOGO_URL` | -- | URL to the logo shown in embeds |
|
| `HEALTHCHECK_HOST` | *(all interfaces)* | e.g. `127.0.0.1` for local-only bind. |
|
||||||
| `EMAIL_SIGNATURE` | -- | HTML signature appended to outgoing emails (use `\n` for line breaks) |
|
| `BOSSCORD_API_KEY` | — | If set, mounts **`/api`** (bOSScord); use a strong random key. |
|
||||||
| `TICKET_CLOSE_SUBJECT_PREFIX` | `[Resolved]` | Prefix added to the subject of closure emails |
|
| `BOSSCORD_CORS_ORIGIN` | `*` | Optional CORS for the API. |
|
||||||
| `TICKET_CLOSE_MESSAGE` | *(see config.js)* | Body of the ticket closure email |
|
|
||||||
| `TICKET_CLOSE_SIGNATURE` | *(see config.js)* | Signature on the closure email |
|
|
||||||
| `TICKET_WELCOME_MESSAGE` | *(see config.js)* | Message posted when a ticket channel is created |
|
|
||||||
| `TICKET_CLAIMED_MESSAGE` | *(see config.js)* | Message posted when a ticket is claimed (supports `{staff_name}`) |
|
|
||||||
| `TICKET_UNCLAIMED_MESSAGE` | *(see config.js)* | Message posted when a ticket is unclaimed |
|
|
||||||
|
|
||||||
### Automation
|
### Messaging & branding
|
||||||
|
|
||||||
| Variable | Default | Description |
|
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`.
|
||||||
|---|---|---|
|
|
||||||
| `AUTO_CLOSE_ENABLED` | `false` | Enable automatic ticket closure after inactivity |
|
|
||||||
| `AUTO_CLOSE_AFTER_HOURS` | `72` | Hours of inactivity before auto-close triggers |
|
|
||||||
| `AUTO_CLOSE_MESSAGE` | *(see config.js)* | Message sent when a ticket is auto-closed |
|
|
||||||
| `REMINDER_ENABLED` | `false` | Enable inactivity reminder messages |
|
|
||||||
| `REMINDER_AFTER_HOURS` | `24` | Hours of inactivity before a reminder is sent |
|
|
||||||
| `REMINDER_MESSAGE` | *(see config.js)* | Reminder message (supports `{hours}` variable) |
|
|
||||||
|
|
||||||
### Ticket Limits & Permissions
|
### Automation & limits
|
||||||
|
|
||||||
| Variable | Default | Description |
|
- **Auto-close:** `AUTO_CLOSE_ENABLED`, `AUTO_CLOSE_AFTER_HOURS`, `AUTO_CLOSE_MESSAGE`.
|
||||||
|---|---|---|
|
- **Reminders:** `REMINDER_ENABLED`, `REMINDER_AFTER_HOURS`, `REMINDER_MESSAGE` (`{ping}`, `{hours}`).
|
||||||
| `GLOBAL_TICKET_LIMIT` | `5` | Maximum concurrent open tickets globally |
|
- **Limits:** `GLOBAL_TICKET_LIMIT`, `TICKET_LIMIT_PER_CATEGORY`, `RATE_LIMIT_TICKETS_PER_USER`, `RATE_LIMIT_WINDOW_MINUTES`.
|
||||||
| `TICKET_LIMIT_PER_CATEGORY` | `3` | Maximum tickets per category |
|
- **Claim:** `ALLOW_CLAIM_OVERWRITE`, `AUTO_UNCLAIM_*`, `CLAIM_TIMEOUT_*`.
|
||||||
| `RATE_LIMIT_TICKETS_PER_USER` | `0` | Max tickets a user can create per window (0 = disabled) |
|
- **Priority:** `PRIORITY_ENABLED`, `DEFAULT_PRIORITY`, `PRIORITY_*_EMOJI`.
|
||||||
| `RATE_LIMIT_WINDOW_MINUTES` | `60` | Window in minutes for per-user ticket creation limit |
|
|
||||||
| `BLACKLISTED_ROLES` | -- | Comma-separated role IDs that cannot open tickets |
|
|
||||||
| `ADDITIONAL_STAFF_ROLES` | -- | Comma-separated role IDs with staff-level permissions |
|
|
||||||
|
|
||||||
### Priority Levels
|
### Game list
|
||||||
|
|
||||||
| Variable | Default | Description |
|
`GAME_LIST=comma,separated,names` — used for detection/normalization in email handling.
|
||||||
|---|---|---|
|
|
||||||
| `PRIORITY_ENABLED` | `false` | Enable the priority system |
|
|
||||||
| `DEFAULT_PRIORITY` | `normal` | Default priority for new tickets |
|
|
||||||
| `PRIORITY_HIGH_EMOJI` | `🔴` | Emoji for high-priority tickets |
|
|
||||||
| `PRIORITY_MEDIUM_EMOJI` | `🟡` | Emoji for normal/medium-priority tickets (default level is normal) |
|
|
||||||
| `PRIORITY_LOW_EMOJI` | `🟢` | Emoji for low-priority tickets |
|
|
||||||
|
|
||||||
### Claiming Options
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `AUTO_UNCLAIM_ENABLED` | `false` | Automatically unclaim tickets after inactivity |
|
|
||||||
| `AUTO_UNCLAIM_AFTER_HOURS` | `24` | Hours before auto-unclaim triggers |
|
|
||||||
| `ALLOW_CLAIM_OVERWRITE` | `false` | Allow claiming an already-claimed ticket |
|
|
||||||
| `CLAIM_TIMEOUT_ENABLED` | `false` | Enable claim timeout |
|
|
||||||
| `CLAIM_TIMEOUT_HOURS` | `48` | Hours before a claim times out |
|
|
||||||
|
|
||||||
### Channel rename rate limit
|
|
||||||
|
|
||||||
Ticket channels are renamed automatically when you **claim**, **unclaim**, **escalate**, or **deescalate**. [Discord’s API](https://discord.com/developers/docs/topics/rate-limits) allows **2 channel renames per 10 minutes** per channel. The bot enforces this: if the limit is reached, the rename is skipped and the channel gets:
|
|
||||||
|
|
||||||
**Channel renamed too quickly. Try again \<t:*unlock_timestamp*:R\>.**
|
|
||||||
|
|
||||||
The timestamp is a Discord relative-time marker (e.g. “in 8 minutes”). After the window resets, the next claim/unclaim/escalate/deescalate can rename again.
|
|
||||||
|
|
||||||
### Button & Embed Customization
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `BUTTON_LABEL_CLOSE` | `Close Ticket` | Label for the close button |
|
|
||||||
| `BUTTON_LABEL_CLAIM` | `Claim` | Label for the claim button |
|
|
||||||
| `BUTTON_LABEL_UNCLAIM` | `Unclaim` | Label for the unclaim button |
|
|
||||||
| `BUTTON_EMOJI_CLOSE` | `🔒` | Emoji on the close button |
|
|
||||||
| `BUTTON_EMOJI_CLAIM` | `📌` | Emoji on the claim button |
|
|
||||||
| `BUTTON_EMOJI_UNCLAIM` | `🔓` | Emoji on the unclaim button |
|
|
||||||
| `EMBED_COLOR_OPEN` | `0x00FF00` | Embed color for open tickets |
|
|
||||||
| `EMBED_COLOR_CLOSED` | `0xFF0000` | Embed color for closed tickets |
|
|
||||||
| `EMBED_COLOR_CLAIMED` | `0xFFFF00` | Embed color for claimed tickets |
|
|
||||||
| `EMBED_COLOR_ESCALATED` | `0xFF6600` | Embed color for escalated tickets |
|
|
||||||
| `EMBED_COLOR_INFO` | `0x1e2124` | Embed color for info messages (and embeds next to ticket buttons) |
|
|
||||||
|
|
||||||
### Game List
|
|
||||||
|
|
||||||
Set `GAME_LIST` to a comma-separated list of game names. The bot uses this list for auto-detection from email subjects/bodies:
|
|
||||||
|
|
||||||
```env
|
|
||||||
GAME_LIST=Project Zomboid, Satisfactory, Palworld, Minecraft, Valheim, ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running the Bot
|
## Staff personal categories & mirror channels
|
||||||
|
|
||||||
|
When configured:
|
||||||
|
|
||||||
|
1. **Claim:** DB stores `claimerId` (Discord user id) and optional `staffChannelId`. Main channel may **move** to `STAFF_CATEGORIES.get(claimerId)`. A **mirror** text channel may be created under that staffer’s category, with a **pinned embed** (ticket number, customer, game, subject, link to original channel).
|
||||||
|
2. **Customer / other user messages** in the real ticket channel: mirror channel gets a **ping**, quote (truncated), and **jump link**; if the claimer enabled **`/notifydm`**, they also get a **DM** (`StaffSettings` in MongoDB).
|
||||||
|
3. **Unclaim:** mirror channel is **deleted**; `claimerId` / `staffChannelId` cleared; channel may rename with 🟢 and move to `STAFF_T1_CATEGORY` if set.
|
||||||
|
4. **Escalation / de-escalation:** slash and button flows may apply **priority emojis** to names and move channels to `STAFF_T2_CATEGORY` / `STAFF_T3_CATEGORY` / `STAFF_T1_CATEGORY` when configured; mirror channels can move with the tier category.
|
||||||
|
5. **Close:** mirror channel is **deleted** when the ticket closes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running the bot (test and Docker)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the bot
|
|
||||||
npm start
|
npm start
|
||||||
|
# or
|
||||||
# Or directly
|
|
||||||
node broccolini-discord.js
|
node broccolini-discord.js
|
||||||
```
|
```
|
||||||
|
|
||||||
On startup the bot will:
|
**Test:**
|
||||||
|
|
||||||
1. Validate required environment variables
|
|
||||||
2. Connect to MongoDB (with automatic reconnection)
|
|
||||||
3. Register all slash commands to the configured guild
|
|
||||||
4. Begin polling Gmail every 30 seconds
|
|
||||||
5. Start background jobs (auto-close, reminders, auto-unclaim)
|
|
||||||
6. Launch an Express healthcheck server
|
|
||||||
|
|
||||||
**Note:** Changing `.env` requires restarting the bot. Slash commands are registered on startup; if commands don’t update, restart the bot to re-register.
|
|
||||||
|
|
||||||
### Test Environment
|
|
||||||
|
|
||||||
To try config changes without affecting production, use a **test env**. Copy `.env.test.example` to `.env.test`, fill it with test-only values (e.g. test guild, test MongoDB database), and run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run start:test
|
npm run start:test
|
||||||
|
npm run test-mongodb:test
|
||||||
```
|
```
|
||||||
|
|
||||||
Other test scripts: `npm run test-mongodb:test`. After confirming behavior in test, migrate only the desired variables to `.env`. See **[ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md)** for the full workflow, security checklist, and agent rules.
|
**Docker** (see [`Dockerfile`](Dockerfile)):
|
||||||
|
|
||||||
To test the MongoDB connection from the repo root: `npm run test-mongodb`.
|
```bash
|
||||||
|
docker build -t broccolini-bot .
|
||||||
---
|
docker run --env-file .env -p 5000:5000 broccolini-bot
|
||||||
|
|
||||||
## Discord Commands
|
|
||||||
|
|
||||||
### Ticket Management
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `/claim` | Claim the current ticket |
|
|
||||||
| `/unclaim` | Release your claim on the current ticket |
|
|
||||||
| `/close` | Close the current ticket (with confirmation) |
|
|
||||||
| `/force-close` | Close the current ticket without confirmation |
|
|
||||||
| `/priority <level>` | Set ticket priority (`low`, `normal`, `medium`, `high`). Posts: *upgraded to [Emoji][Level][Emoji]*, *downgraded to...*, or *returned to Normal*. Email sent when set to **high**. |
|
|
||||||
| `/topic <text>` | Set the ticket channel topic |
|
|
||||||
| `/escalate [reason] [tier]` | Escalate the ticket to tier 2 or 3 (optional tier; buttons also available) |
|
|
||||||
| `/deescalate` | De-escalate the ticket one step |
|
|
||||||
|
|
||||||
### User & Channel Management
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `/add <user>` | Add a user to the current ticket channel |
|
|
||||||
| `/remove <user>` | Remove a user from the current ticket channel |
|
|
||||||
| `/transfer <staff>` | Transfer the ticket to another staff member |
|
|
||||||
| `/move <category>` | Move the ticket to a different category |
|
|
||||||
|
|
||||||
### Tags & Saved Responses
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `/tag` | Set ticket category (dropdown: ⬇️ Server Down, ⏳ Stuck Restarting, 📵 Can't Connect, 🐌 Server Lag, 💳 Billing, 💸 Refund Request, 🔧 Mod Help, 💾 Backup Restore, 🌍 World / Save, ⚙️ Server Config). Posts: *Your ticket has been categorized as [Emoji][Tag][Emoji].* |
|
|
||||||
| `/response send <name>` | Send a saved response (autocomplete-enabled) |
|
|
||||||
| `/response create <name> <content>` | Create a new saved response |
|
|
||||||
| `/response edit <name> <content>` | Edit an existing saved response |
|
|
||||||
| `/response delete <name>` | Delete a saved response |
|
|
||||||
| `/response list` | List all saved responses |
|
|
||||||
|
|
||||||
### Utilities
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `/panel [channel] [type] [title] [description]` | Deploy a ticket-creation panel (type: thread, category, or both) |
|
|
||||||
| `/email-routing` | Switch where new email tickets are created (threads or category channels) |
|
|
||||||
| `/accountinfo <email or discord>` | Look up a user's account information |
|
|
||||||
| `/search <query>` | Search tickets |
|
|
||||||
| `/stats` | Show bot statistics and analytics |
|
|
||||||
| `/backup` | Export full ticket list to a .txt file in the backup/export channel |
|
|
||||||
| `/export [status] [limit]` | Export tickets (optional filter and limit) to a .txt file in the backup/export channel |
|
|
||||||
| `/help` | Display the command reference |
|
|
||||||
|
|
||||||
### Context Menus
|
|
||||||
|
|
||||||
| Menu | Description |
|
|
||||||
|---|---|
|
|
||||||
| **Create Ticket From Message** | Right-click a message to create a ticket from it |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tag & Response System
|
|
||||||
|
|
||||||
### Ticket category (`/tag`)
|
|
||||||
|
|
||||||
Use `/tag` in a ticket channel and pick a category from the dropdown (e.g. ⬇️ Server Down, 💳 Billing, 🔧 Mod Help). The bot posts: *Your ticket has been categorized as [Emoji][Tag][Emoji].* Channel name is not changed.
|
|
||||||
|
|
||||||
### Saved response tags (`/response`)
|
|
||||||
|
|
||||||
Saved responses are reusable templates stored in MongoDB. Use `/response send`, `/response create`, etc. They support dynamic variables that are replaced at send time:
|
|
||||||
|
|
||||||
| Variable | Resolves To |
|
|
||||||
|---|---|
|
|
||||||
| `{ticket.user}` | Ticket sender's name |
|
|
||||||
| `{ticket.email}` | Ticket sender's email |
|
|
||||||
| `{ticket.number}` | Ticket number |
|
|
||||||
| `{ticket.subject}` | Ticket subject line |
|
|
||||||
| `{staff.name}` | Current staff member's display name |
|
|
||||||
| `{staff.mention}` | Current staff member's mention |
|
|
||||||
| `{server.name}` | Discord server name |
|
|
||||||
| `{date}` | Current date |
|
|
||||||
| `{time}` | Current time |
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```
|
|
||||||
/response create name:greeting content:Hi {ticket.user}! Thanks for reaching out about "{ticket.subject}". I'm {staff.name} and I'll be helping you today.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Ensure `MONGODB_URI` and Discord token are available inside the container.
|
||||||
|
|
||||||
## Panel System
|
|
||||||
|
|
||||||
The panel system allows users to create tickets directly from Discord without sending an email.
|
|
||||||
|
|
||||||
1. Deploy a panel: `/panel #support title:Need Help? description:Click below to open a ticket!`
|
|
||||||
2. Users click the **Open Ticket** button
|
|
||||||
3. A modal form appears asking for subject, description, and priority
|
|
||||||
4. On submission, a ticket channel is created with all the same features as email tickets
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## 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`** | `on` / `off` — DM when a **non-claimer** replies in a ticket you claimed (mirror ping still applies). |
|
||||||
|
| **`/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. |
|
||||||
|
| **`/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 TSV 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** (not “priority” in the modal).
|
||||||
|
- 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 a fixed list (Server Down, Billing, etc.). 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-bot/
|
||||||
├── broccolini-discord.js # Entry point - initializes bot, events, and jobs
|
├── broccolini-discord.js # Entry: Discord client, Express, Gmail poll interval, jobs
|
||||||
├── config.js # Environment variable loading and CONFIG export
|
├── config.js # Env → CONFIG (+ STAFF_CATEGORIES map, game lists)
|
||||||
├── db-connection.js # MongoDB connection with reconnect logic
|
├── db-connection.js # Mongo connect + require models
|
||||||
├── models.js # Mongoose schemas (Ticket, User, Tag, etc.)
|
├── models.js # Mongoose schemas (Ticket, Tag, StaffSettings, …)
|
||||||
├── utils.js # Text processing, game detection, template vars
|
├── utils.js # Email/game helpers, template variables
|
||||||
├── gmail-poll.js # Gmail polling loop and ticket creation
|
├── utils/ticketComponents.js # Action row builders
|
||||||
├── game-options.json # Game configuration data
|
├── gmail-poll.js # Ingest Gmail → Discord ticket creation
|
||||||
│
|
├── get-refresh-token.js # One-shot OAuth refresh token helper
|
||||||
├── commands/
|
├── commands/register.js # Slash + context menu registration (discord.js v14)
|
||||||
│ └── register.js # Slash command and context menu registration
|
|
||||||
│
|
|
||||||
├── handlers/
|
├── handlers/
|
||||||
│ ├── accountinfo.js # /accountinfo command and button handler
|
│ ├── buttons.js # Claim/close/modals/escalate buttons, ticket create modal
|
||||||
│ ├── analytics.js # In-memory analytics and error tracking
|
│ ├── commands.js # Slash handlers, runEscalation/runDeescalation
|
||||||
│ ├── buttons.js # Button interactions (claim, close, priority, etc.)
|
│ ├── messages.js # Staff ↔ Gmail relay; mirror pings + notifydm
|
||||||
│ ├── commands.js # All slash command handlers
|
│ ├── accountinfo.js
|
||||||
│ ├── messages.js # Discord → Gmail reply forwarding
|
│ ├── analytics.js
|
||||||
│ └── setup.js # Guild setup / configuration flow
|
│ └── setup.js
|
||||||
│
|
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── debugLog.js # Structured debug logging
|
│ ├── gmail.js
|
||||||
│ ├── gmail.js # Gmail OAuth2, send replies, closure emails
|
│ ├── tickets.js # Auto-close, reminders, auto-unclaim, naming helpers
|
||||||
│ ├── guildSettings.js # Guild-specific settings (DB + cache)
|
│ ├── channelQueue.js # enqueueRename / enqueueMove
|
||||||
│ └── tickets.js # Ticket CRUD, auto-close, reminders, auto-unclaim
|
│ ├── staffChannel.js # Mirror create/ping/move/delete
|
||||||
│
|
│ ├── staffSettings.js # notifydm prefs
|
||||||
├── scripts/
|
│ ├── guildSettings.js
|
||||||
│ ├── backup-env.js # Copy .env to .env.backup
|
│ └── debugLog.js
|
||||||
│ └── test-mongodb.js # MongoDB connection test
|
├── routes/bosscord.js # Optional /api routes
|
||||||
│
|
├── api/bosscordClient.js
|
||||||
├── docs/ # Additional documentation (QUICKSTART, MONGODB_SETUP, ENV_AND_SECURITY, etc.)
|
├── scripts/ # Maintenance / one-off utilities
|
||||||
├── .env # Environment variables (not committed)
|
├── docs/ # Deeper guides (setup, security, MongoDB, API notes)
|
||||||
|
├── Dockerfile
|
||||||
├── package.json
|
├── package.json
|
||||||
└── package-lock.json
|
└── .env.example / .env.test.example
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Database Schema
|
## Database collections
|
||||||
|
|
||||||
The bot uses MongoDB via Mongoose. Key collections:
|
| Model / collection | Role |
|
||||||
|
|--------------------|------|
|
||||||
| Collection | Purpose |
|
| **Ticket Gmail thread id, Discord channel/thread id, status, priority, claim (`claimedBy` display name), `claimerId`, `staffChannelId`, escalation tier, `welcomeMessageId`, `ticketTag`, etc.** |
|
||||||
|---|---|
|
| **TicketCounter** | Per-sender local counters (legacy paths). |
|
||||||
| `Ticket` | Core ticket data: Gmail thread ID, Discord channel ID, sender info, status, priority, claimed-by, timestamps |
|
| **Transcript** | Links closed tickets to transcript message IDs. |
|
||||||
| `TicketCounter` | Auto-incrementing ticket numbers per sender |
|
| **Tag** | Saved response name + content. |
|
||||||
| `Transcript` | Transcript message references for closed tickets |
|
| **GuildSettings** | e.g. `emailRouting`: `thread` \| `category`. |
|
||||||
| `Tag` | Saved response templates (name, content, creator) |
|
| **StaffSettings** | Per-user `notifyDm` (+ `guildId`, `updatedAt`). |
|
||||||
| `CloseRequest` | Tracks pending close confirmations |
|
| **CloseRequest** | Pending close workflow if used. |
|
||||||
| `User` | Website user accounts (email, Discord ID, linked servers) |
|
| **User**, **Host**, **DashboardMetrics**, **ErrorLog** | Shared / website-era schemas in the same `models.js` file. |
|
||||||
| `Host` | Game server/host metadata and metrics |
|
|
||||||
| `DashboardMetrics` | Aggregated dashboard statistics |
|
|
||||||
| `ErrorLog` | Persisted error records |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API Integrations
|
## HTTP: healthcheck & bOSScord API
|
||||||
|
|
||||||
### Gmail API
|
- **`GET /`** → plain text **`Active`** (intended for load balancers / Docker `HEALTHCHECK`).
|
||||||
|
- **`/api/*`** is registered **only after** `ready` when `BOSSCORD_API_KEY` is set. JSON body parsing enabled. See [`routes/bosscord.js`](routes/bosscord.js) for routes.
|
||||||
- **Authentication:** OAuth2 with Client ID, Client Secret, and Refresh Token
|
|
||||||
- **Polling:** `users.messages.list` for unread messages in the primary inbox
|
|
||||||
- **Reading:** `users.messages.get` to fetch full message content
|
|
||||||
- **Sending:** `users.messages.send` for threaded replies and closure emails
|
|
||||||
|
|
||||||
### Discord API (discord.js v14)
|
|
||||||
|
|
||||||
- **Intents:** Guilds, GuildMessages, MessageContent, GuildMembers
|
|
||||||
- **Interactions:** Slash commands, buttons, modals, context menus, autocomplete
|
|
||||||
- **Channels:** Create/delete ticket channels, manage permissions per user
|
|
||||||
|
|
||||||
## Healthcheck
|
|
||||||
|
|
||||||
An Express server runs on the port defined by `DISCORD_ONLY_PORT` (default: `5000`).
|
|
||||||
|
|
||||||
```
|
|
||||||
GET / → "Active"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use this endpoint for uptime monitoring or container health probes. Optional: set `HEALTHCHECK_HOST=127.0.0.1` in `.env` to bind the healthcheck server to localhost only; omit to listen on all interfaces.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Gmail OAuth refresh token
|
||||||
|
|
||||||
Additional guides and reference docs live in **`docs/`**. See [docs/README.md](docs/README.md) for the full index.
|
```bash
|
||||||
|
node get-refresh-token.js
|
||||||
|
```
|
||||||
|
|
||||||
| Doc | Description |
|
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`.
|
||||||
|-----|-------------|
|
|
||||||
| [QUICKSTART](docs/setup/QUICKSTART.md) | Get started in a few minutes: first response, panel, tags, priority |
|
---
|
||||||
| [ENV_AND_SECURITY](docs/setup/ENV_AND_SECURITY.md) | Test env workflow, security checklist, agent rules |
|
|
||||||
| [MONGODB_SETUP](docs/setup/MONGODB_SETUP.md) | MongoDB connection, schemas, and testing |
|
## Documentation in `docs/`
|
||||||
| [PROJECT_STRUCTURE](docs/setup/PROJECT_STRUCTURE.md) | File and directory layout |
|
|
||||||
| [PROPOSAL](docs/features/PROPOSAL.md) | Roadmap and possible next steps |
|
Index: **[docs/README.md](docs/README.md)**. Highlights:
|
||||||
| [PHASE_FEATURES](docs/features/PHASE_FEATURES.md) | Phased feature list and variables |
|
|
||||||
| [FEATURES_SUMMARY](docs/features/FEATURES_SUMMARY.md) · [NEW_FEATURES](docs/features/NEW_FEATURES.md) | Feature overview and changelog |
|
| Doc | Topic |
|
||||||
| [DISCORD_API_IMPROVEMENTS](docs/api/DISCORD_API_IMPROVEMENTS.md) · [DISCORD_API_VALIDATION](docs/api/DISCORD_API_VALIDATION.md) | Discord API implementation notes |
|
|-----|--------|
|
||||||
|
| [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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Slash commands not appearing in Discord
|
| Symptom | Checks |
|
||||||
|
|---------|--------|
|
||||||
- Commands are registered per-guild on startup. Wait up to one hour for Discord to propagate.
|
| **Commands missing** | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; **restart** bot; Discord can take time to sync. |
|
||||||
- Verify `DISCORD_APPLICATION_ID` and `DISCORD_GUILD_ID` are correct.
|
| **Gmail not ingesting** | `REFRESH_TOKEN`, API enablement, inbox auth; regenerate token if revoked. |
|
||||||
- Restart the bot.
|
| **MongoDB errors** | `MONGODB_URI`, Atlas IP allowlist, `npm run test-mongodb`. |
|
||||||
|
| **Channels not creating** | Bot **Manage Channels** in ticket categories; category not full (50) unless overflow set. |
|
||||||
### Gmail polling not working
|
| **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. |
|
||||||
- Ensure `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `REFRESH_TOKEN` are set correctly.
|
|
||||||
- The refresh token may have expired -- regenerate it via the Google OAuth2 Playground.
|
|
||||||
- Check that the Gmail API is enabled in your Google Cloud Console project.
|
|
||||||
|
|
||||||
### MongoDB connection failures
|
|
||||||
|
|
||||||
- Verify `MONGODB_URI` is correct and the database is accessible.
|
|
||||||
- Run `npm run test-mongodb` from the repo root to test the connection.
|
|
||||||
- If using MongoDB Atlas, ensure your IP is whitelisted.
|
|
||||||
- The bot has automatic reconnection -- check logs for retry attempts.
|
|
||||||
|
|
||||||
### Tickets not creating
|
|
||||||
|
|
||||||
- Check that `TICKET_CATEGORY_ID` points to a valid Discord category.
|
|
||||||
- Ensure the bot has `Manage Channels` and `View Channel` permissions in that category.
|
|
||||||
- Review the logging channel for error messages.
|
|
||||||
|
|
||||||
### Modal not appearing when clicking "Open Ticket"
|
|
||||||
|
|
||||||
- Verify the bot has proper guild permissions.
|
|
||||||
- Try in a different channel.
|
|
||||||
- Restart the bot.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
This project builds on or references the following:
|
| Technology | Link |
|
||||||
|
|------------|------|
|
||||||
| Technology | Description | Links |
|
| discord.js v14 | [discord.js guide](https://discordjs.guide/) |
|
||||||
|------------|-------------|--------|
|
| Google APIs (Gmail) | [googleapis Node](https://github.com/googleapis/google-api-nodejs-client) |
|
||||||
| **discord.js** | Node.js library for the Discord API; used for the bot, slash commands, buttons, and embeds. | [discord.js](https://discord.js.org/) · [GitHub](https://github.com/discordjs/discord.js) |
|
| Mongoose | [mongoosejs.com](https://mongoosejs.com/) |
|
||||||
| **Discord Tickets** | Open-source ticket bot; referenced for patterns and feature inspiration (panels, tags, transcripts). | [Discord Tickets](https://discordtickets.app/) · [GitHub](https://github.com/discord-tickets/bot) |
|
| Express | [expressjs.com](https://expressjs.com/) |
|
||||||
| **Node.js** | JavaScript runtime used to run the bot. | [Node.js](https://nodejs.org/en) |
|
|
||||||
| **MongoDB** | Database for tickets, transcripts, and persistence (via Mongoose). | [MongoDB](https://www.mongodb.com/) |
|
|
||||||
| **Express** | HTTP server for the healthcheck endpoint. | [Express](https://expressjs.com/) |
|
|
||||||
| **Mongoose** | MongoDB ODM used for schemas and connection handling. | [Mongoose](https://mongoosejs.com/) |
|
|
||||||
| **Google APIs (googleapis)** | Gmail API client for polling and sending email. | [Google APIs Node.js](https://github.com/googleapis/google-api-nodejs-client) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const CONFIG = {
|
|||||||
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
|
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
|
||||||
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
|
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
|
||||||
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
|
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
|
||||||
|
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
|
||||||
|
TICKET_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 || '')
|
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(s => s.trim())
|
.map(s => s.trim())
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const {
|
|||||||
getFormattedDate
|
getFormattedDate
|
||||||
} = require('./utils');
|
} = require('./utils');
|
||||||
const { getGmailClient } = require('./services/gmail');
|
const { getGmailClient } = require('./services/gmail');
|
||||||
const { getNextTicketNumber, checkTicketLimits, pickTicketCategoryId, createEmailTicketAsThread } = require('./services/tickets');
|
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread } = require('./services/tickets');
|
||||||
const { getEmailRouting } = require('./services/guildSettings');
|
const { getEmailRouting } = require('./services/guildSettings');
|
||||||
const { logError } = require('./services/debugLog');
|
const { logError } = require('./services/debugLog');
|
||||||
|
|
||||||
@@ -125,8 +125,9 @@ async function poll(client) {
|
|||||||
.select('gmailThreadId discordThreadId status')
|
.select('gmailThreadId discordThreadId status')
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
let ticketChan = null;
|
let ticketChan = null;
|
||||||
let isReopened = false;
|
let parentCategoryIdForTicket = null;
|
||||||
|
let isReopened = false;
|
||||||
|
|
||||||
if (existing && existing.discordThreadId) {
|
if (existing && existing.discordThreadId) {
|
||||||
ticketChan = await guild.channels
|
ticketChan = await guild.channels
|
||||||
@@ -166,17 +167,24 @@ async function poll(client) {
|
|||||||
const routing = await getEmailRouting(guild.id);
|
const routing = await getEmailRouting(guild.id);
|
||||||
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
|
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
|
||||||
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
|
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
|
||||||
|
parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null;
|
||||||
} else {
|
} else {
|
||||||
const emailCategoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
const parentId = await getOrCreateTicketCategory(
|
||||||
const parentId = pickTicketCategoryId(guild, emailCategoryIds);
|
guild,
|
||||||
if (!parentId) {
|
CONFIG.TICKET_CATEGORY_ID,
|
||||||
throw new Error('Email ticket category not found or all categories full (50 channels max)');
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
|
);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
ticketChan = await guild.channels.create({
|
|
||||||
name: chanName,
|
|
||||||
type: ChannelType.GuildText,
|
|
||||||
parent: parentId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Channel create error (payload):', {
|
console.error('Channel create error (payload):', {
|
||||||
@@ -297,7 +305,8 @@ async function poll(client) {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
ticketNumber: number,
|
ticketNumber: number,
|
||||||
priority: defaultPriority,
|
priority: defaultPriority,
|
||||||
lastActivity: now
|
lastActivity: now,
|
||||||
|
parentCategoryId: parentCategoryIdForTicket
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ upsert: true, new: true }
|
{ upsert: true, new: true }
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const {
|
|||||||
} = require('discord.js');
|
} = require('discord.js');
|
||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG } = require('../config');
|
const { CONFIG } = require('../config');
|
||||||
const { canRename, makeTicketName, minutesFromMs, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
const { canRename, makeTicketName, minutesFromMs, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { setEmailRouting } = require('../services/guildSettings');
|
const { setEmailRouting } = require('../services/guildSettings');
|
||||||
@@ -174,10 +174,13 @@ async function handleButton(interaction) {
|
|||||||
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
|
return interaction.reply({ content: 'Tier 2 (ESCALATED2) is not configured for this ticket type.', ephemeral: true });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
await runEscalation(interaction, ticket, 1, 'Escalated via button (Tier 2)');
|
await runEscalation(interaction, ticket, 1, 'Escalated via button (Tier 2)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
trackError('escalate-button-tier2', err, interaction);
|
trackError('escalate-button-tier2', err, interaction);
|
||||||
await interaction.reply({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {});
|
await interaction.editReply({ content: 'Failed to escalate to tier 2.' }).catch(() =>
|
||||||
|
interaction.followUp({ content: 'Failed to escalate to tier 2.', ephemeral: true }).catch(() => {})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -194,10 +197,13 @@ async function handleButton(interaction) {
|
|||||||
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
|
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
await runEscalation(interaction, ticket, 2, 'Escalated via button (Tier 3)');
|
await runEscalation(interaction, ticket, 2, 'Escalated via button (Tier 3)');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
trackError('escalate-button-tier3', err, interaction);
|
trackError('escalate-button-tier3', err, interaction);
|
||||||
await interaction.reply({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {});
|
await interaction.editReply({ content: 'Failed to escalate to tier 3.' }).catch(() =>
|
||||||
|
interaction.followUp({ content: 'Failed to escalate to tier 3.', ephemeral: true }).catch(() => {})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,10 +215,13 @@ async function handleButton(interaction) {
|
|||||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
await runDeescalation(interaction, ticket);
|
await runDeescalation(interaction, ticket);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
trackError('deescalate-button', err, interaction);
|
trackError('deescalate-button', err, interaction);
|
||||||
await interaction.reply({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {});
|
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
|
||||||
|
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -569,10 +578,20 @@ async function handleConfirmClose(interaction, ticket) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parentCatId = ticket.parentCategoryId;
|
||||||
|
const guildRef = interaction.guild;
|
||||||
|
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() => interaction.channel.delete().catch(() => {}),
|
() => interaction.channel.delete().catch(() => {}),
|
||||||
5000
|
5000
|
||||||
);
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
(async () => {
|
||||||
|
if (parentCatId && guildRef) {
|
||||||
|
await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, 6000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Close ticket error:', e);
|
console.error('Close ticket error:', e);
|
||||||
}
|
}
|
||||||
@@ -606,9 +625,11 @@ async function handleTicketModal(interaction) {
|
|||||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||||
|
|
||||||
let channel;
|
let channel;
|
||||||
|
let parentCategoryIdForTicket = null;
|
||||||
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||||
try {
|
try {
|
||||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
|
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
|
||||||
|
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Discord ticket thread create failed:', err.message);
|
console.error('Discord ticket thread create failed:', err.message);
|
||||||
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
|
return interaction.editReply('Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID and try again.');
|
||||||
@@ -616,27 +637,39 @@ async function handleTicketModal(interaction) {
|
|||||||
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
} else if (useThread && !CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||||
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
|
return interaction.editReply('Thread tickets are not configured (DISCORD_THREAD_CHANNEL_ID is not set). Use a channel panel or set the env variable.');
|
||||||
} else {
|
} else {
|
||||||
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
let parentId;
|
||||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
try {
|
||||||
if (!parentId) {
|
parentId = await getOrCreateTicketCategory(
|
||||||
return interaction.editReply('Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
|
guild,
|
||||||
|
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||||
|
CONFIG.TICKET_CATEGORY_NAME
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('getOrCreateTicketCategory (ticket modal):', err);
|
||||||
|
return interaction.editReply('Discord ticket category could not be resolved. Contact an administrator.');
|
||||||
|
}
|
||||||
|
parentCategoryIdForTicket = parentId;
|
||||||
|
try {
|
||||||
|
channel = await guild.channels.create({
|
||||||
|
name: `ticket-${ticketNumber}`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: parentId,
|
||||||
|
permissionOverwrites: [
|
||||||
|
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||||
|
{
|
||||||
|
id: interaction.user.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 (ticket modal):', err);
|
||||||
|
return interaction.editReply('Failed to create ticket channel. Contact an administrator.');
|
||||||
}
|
}
|
||||||
channel = await guild.channels.create({
|
|
||||||
name: `ticket-${ticketNumber}`,
|
|
||||||
type: ChannelType.GuildText,
|
|
||||||
parent: parentId,
|
|
||||||
permissionOverwrites: [
|
|
||||||
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
|
|
||||||
{
|
|
||||||
id: interaction.user.id,
|
|
||||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: CONFIG.ROLE_ID_TO_PING,
|
|
||||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
|
const gmailThreadId = `discord-${Date.now()}-${interaction.user.id}`;
|
||||||
@@ -651,7 +684,8 @@ async function handleTicketModal(interaction) {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
priority,
|
priority,
|
||||||
lastActivity: now
|
lastActivity: now,
|
||||||
|
parentCategoryId: parentCategoryIdForTicket
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayName = interaction.member?.displayName || interaction.user.username;
|
const displayName = interaction.member?.displayName || interaction.user.username;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const {
|
|||||||
const { mongoose } = require('../db-connection');
|
const { mongoose } = require('../db-connection');
|
||||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
const { CONFIG, TICKET_TAGS } = require('../config');
|
||||||
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
|
const { getPriorityEmoji, getPriorityColor, replaceVariables, escapeRegex } = require('../utils');
|
||||||
const { canRename, makeTicketName, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
const { canRename, makeTicketName, getOrCreateTicketCategory, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||||
const { getEmailRouting } = require('../services/guildSettings');
|
const { getEmailRouting } = require('../services/guildSettings');
|
||||||
@@ -118,7 +118,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
|||||||
const pendingEmbed = new EmbedBuilder()
|
const pendingEmbed = new EmbedBuilder()
|
||||||
.setDescription('Ticket will be escalated in a few seconds.')
|
.setDescription('Ticket will be escalated in a few seconds.')
|
||||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||||
await interaction.reply({ content: null, embeds: [pendingEmbed] });
|
await interaction.editReply({ embeds: [pendingEmbed] });
|
||||||
|
|
||||||
const creatorId = isDiscordTicket
|
const creatorId = isDiscordTicket
|
||||||
? (ticket.gmailThreadId.split('-').pop() || '').trim()
|
? (ticket.gmailThreadId.split('-').pop() || '').trim()
|
||||||
@@ -228,10 +228,7 @@ async function runDeescalation(interaction, ticket) {
|
|||||||
.setColor(0x00BFFF)
|
.setColor(0x00BFFF)
|
||||||
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
|
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
|
||||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||||
await interaction.reply({
|
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||||||
embeds: [deescalateEmbed],
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
||||||
if (logChan) {
|
if (logChan) {
|
||||||
@@ -316,6 +313,7 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await interaction.deferReply();
|
||||||
await runEscalation(interaction, ticket, nextTier, reason);
|
await runEscalation(interaction, ticket, nextTier, reason);
|
||||||
if (action === 'unclaim') {
|
if (action === 'unclaim') {
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
@@ -325,7 +323,9 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Escalate error:', err);
|
console.error('Escalate error:', err);
|
||||||
await interaction.reply({ content: 'Failed to escalate this ticket.', ephemeral: true });
|
await interaction.editReply({ content: 'Failed to escalate this ticket.' }).catch(() =>
|
||||||
|
interaction.followUp({ content: 'Failed to escalate this ticket.', ephemeral: true }).catch(() => {})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,10 +357,13 @@ async function handleCommand(interaction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await interaction.deferReply({ ephemeral: true });
|
||||||
await runDeescalation(interaction, ticket);
|
await runDeescalation(interaction, ticket);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Deescalate error:', err);
|
console.error('Deescalate error:', err);
|
||||||
await interaction.reply({ content: 'Failed to deescalate this ticket.', ephemeral: true });
|
await interaction.editReply({ content: 'Failed to deescalate this ticket.' }).catch(() =>
|
||||||
|
interaction.followUp({ content: 'Failed to deescalate this ticket.', ephemeral: true }).catch(() => {})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,35 +1047,49 @@ async function handleContextMenu(interaction) {
|
|||||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||||
|
|
||||||
let channel;
|
let channel;
|
||||||
|
let parentCategoryIdForTicket = null;
|
||||||
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||||
try {
|
try {
|
||||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
|
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
|
||||||
|
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Discord ticket thread create (from message) failed:', err.message);
|
console.error('Discord ticket thread create (from message) failed:', err.message);
|
||||||
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
|
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
let parentId;
|
||||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
try {
|
||||||
if (!parentId) {
|
parentId = await getOrCreateTicketCategory(
|
||||||
return interaction.editReply('❌ Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
|
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.');
|
||||||
|
}
|
||||||
|
parentCategoryIdForTicket = parentId;
|
||||||
|
try {
|
||||||
|
channel = await guild.channels.create({
|
||||||
|
name: `ticket-${ticketNumber}`,
|
||||||
|
type: ChannelType.GuildText,
|
||||||
|
parent: parentId,
|
||||||
|
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.');
|
||||||
}
|
}
|
||||||
channel = await guild.channels.create({
|
|
||||||
name: `ticket-${ticketNumber}`,
|
|
||||||
type: ChannelType.GuildText,
|
|
||||||
parent: parentId,
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
|
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
|
||||||
@@ -1086,7 +1103,8 @@ async function handleContextMenu(interaction) {
|
|||||||
status: 'open',
|
status: 'open',
|
||||||
ticketNumber,
|
ticketNumber,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
lastActivity: now
|
lastActivity: now,
|
||||||
|
parentCategoryId: parentCategoryIdForTicket
|
||||||
});
|
});
|
||||||
|
|
||||||
const welcomeEmbed = new EmbedBuilder()
|
const welcomeEmbed = new EmbedBuilder()
|
||||||
|
|||||||
@@ -814,7 +814,8 @@ mongoose.model('Ticket', new mongoose.Schema({
|
|||||||
reminderSent: { type: Boolean, default: false },
|
reminderSent: { type: Boolean, default: false },
|
||||||
welcomeMessageId: String,
|
welcomeMessageId: String,
|
||||||
claimerId: String,
|
claimerId: String,
|
||||||
staffChannelId: String
|
staffChannelId: String,
|
||||||
|
parentCategoryId: String
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mongoose.model('TicketCounter', new mongoose.Schema({
|
mongoose.model('TicketCounter', new mongoose.Schema({
|
||||||
|
|||||||
@@ -10,7 +10,19 @@ const channelQueue = new PQueue({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function enqueueRename(channel, newName) {
|
function enqueueRename(channel, newName) {
|
||||||
return channelQueue.add(() => channel.setName(newName));
|
return channelQueue.add(async () => {
|
||||||
|
try {
|
||||||
|
await channel.setName(newName);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message || String(err);
|
||||||
|
if (msg.includes('429') || msg.toLowerCase().includes('rate limit')) {
|
||||||
|
console.warn(`enqueueRename: rate limit renaming channel "${channel.name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('enqueueRename:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueueMove(channel, categoryId) {
|
function enqueueMove(channel, categoryId) {
|
||||||
|
|||||||
@@ -46,8 +46,15 @@ function makeTicketName({ escalated, claimed }, ticket, guild) {
|
|||||||
|
|
||||||
async function canRename(ticket) {
|
async function canRename(ticket) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowStart = (ticket.renameWindowStart && new Date(ticket.renameWindowStart).getTime()) || 0;
|
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
|
||||||
let count = ticket.renameCount || 0;
|
.select('renameCount renameWindowStart')
|
||||||
|
.lean();
|
||||||
|
if (!fresh) {
|
||||||
|
return { ok: false, remaining: 0, waitMs: RENAME_WINDOW_MS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowStart = (fresh.renameWindowStart && new Date(fresh.renameWindowStart).getTime()) || 0;
|
||||||
|
const count = fresh.renameCount || 0;
|
||||||
|
|
||||||
if (now - windowStart >= RENAME_WINDOW_MS) {
|
if (now - windowStart >= RENAME_WINDOW_MS) {
|
||||||
await Ticket.updateOne(
|
await Ticket.updateOne(
|
||||||
@@ -59,18 +66,28 @@ async function canRename(ticket) {
|
|||||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = RENAME_LIMIT - count;
|
if (count >= RENAME_LIMIT) {
|
||||||
if (remaining <= 0) {
|
|
||||||
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
|
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
|
||||||
return { ok: false, remaining: 0, waitMs };
|
return { ok: false, remaining: 0, waitMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
await Ticket.updateOne(
|
const updated = await Ticket.findOneAndUpdate(
|
||||||
{ gmailThreadId: ticket.gmailThreadId },
|
{ gmailThreadId: ticket.gmailThreadId },
|
||||||
{ $inc: { renameCount: 1 } }
|
{ $inc: { renameCount: 1 } },
|
||||||
);
|
{ returnDocument: 'after' }
|
||||||
ticket.renameCount = count + 1;
|
)
|
||||||
return { ok: true, remaining: RENAME_LIMIT - (count + 1), waitMs: 0 };
|
.select('renameCount renameWindowStart')
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
|
||||||
|
return { ok: false, remaining: 0, waitMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = updated.renameCount || 0;
|
||||||
|
ticket.renameCount = newCount;
|
||||||
|
ticket.renameWindowStart = updated.renameWindowStart;
|
||||||
|
return { ok: true, remaining: RENAME_LIMIT - newCount, waitMs: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function minutesFromMs(ms) {
|
function minutesFromMs(ms) {
|
||||||
@@ -109,22 +126,124 @@ function checkTicketCreationRateLimit(userId) {
|
|||||||
|
|
||||||
const CHANNELS_PER_CATEGORY_LIMIT = 50;
|
const CHANNELS_PER_CATEGORY_LIMIT = 50;
|
||||||
|
|
||||||
|
function escapeCategoryNameForRegex(name) {
|
||||||
|
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the first category that has room (< 50 channels). Main + overflow IDs in order.
|
* @deprecated Use getOrCreateTicketCategory instead.
|
||||||
* @param {import('discord.js').Guild} guild
|
* @returns {null}
|
||||||
* @param {string[]} categoryIds [mainId, ...overflowIds]
|
|
||||||
* @returns {string|null} category id to use as parent, or null
|
|
||||||
*/
|
*/
|
||||||
function pickTicketCategoryId(guild, categoryIds) {
|
function pickTicketCategoryId(guild, categoryIds) {
|
||||||
if (!guild || !Array.isArray(categoryIds)) return null;
|
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
|
||||||
const list = categoryIds.filter(Boolean);
|
return null;
|
||||||
for (const id of list) {
|
}
|
||||||
const cat = guild.channels.cache.get(id);
|
|
||||||
if (!cat || cat.type !== ChannelType.GuildCategory) continue;
|
function countChannelsInCategory(guild, categoryId) {
|
||||||
const count = guild.channels.cache.filter(c => c.parentId === id).size;
|
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
|
||||||
if (count < CHANNELS_PER_CATEGORY_LIMIT) return id;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve or create a ticket category with dynamic overflow (Discord max 50 channels per category).
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {string} primaryCategoryId
|
||||||
|
* @param {string} categoryName Display base name (primary category should match; overflows are "(Overflow N)")
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function getOrCreateTicketCategory(guild, primaryCategoryId, categoryName) {
|
||||||
|
if (!guild) {
|
||||||
|
throw new Error('getOrCreateTicketCategory: guild is required');
|
||||||
|
}
|
||||||
|
if (!primaryCategoryId || !String(primaryCategoryId).trim()) {
|
||||||
|
throw new Error('getOrCreateTicketCategory: primaryCategoryId is required');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let primary = guild.channels.cache.get(primaryCategoryId);
|
||||||
|
if (!primary) {
|
||||||
|
primary = await guild.channels.fetch(primaryCategoryId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!primary || primary.type !== ChannelType.GuildCategory) {
|
||||||
|
throw new Error(`getOrCreateTicketCategory: primary category ${primaryCategoryId} not found or not a category`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = escapeCategoryNameForRegex(categoryName);
|
||||||
|
const overflowRe = new RegExp(`^${escaped} \\(Overflow (\\d+)\\)$`);
|
||||||
|
|
||||||
|
const overflowMatches = [];
|
||||||
|
for (const ch of guild.channels.cache.values()) {
|
||||||
|
if (!ch || ch.type !== ChannelType.GuildCategory) continue;
|
||||||
|
if (ch.id === primaryCategoryId) continue;
|
||||||
|
const m = ch.name.match(overflowRe);
|
||||||
|
if (m) overflowMatches.push({ ch, n: parseInt(m[1], 10) });
|
||||||
|
}
|
||||||
|
overflowMatches.sort((a, b) => a.n - b.n);
|
||||||
|
|
||||||
|
const existingCategories = [primary, ...overflowMatches.map(x => x.ch)];
|
||||||
|
|
||||||
|
for (const cat of existingCategories) {
|
||||||
|
if (countChannelsInCategory(guild, cat.id) < CHANNELS_PER_CATEGORY_LIMIT) {
|
||||||
|
return cat.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const highestN = overflowMatches.length > 0 ? Math.max(...overflowMatches.map(x => x.n)) : 0;
|
||||||
|
const nextN = highestN + 1;
|
||||||
|
const newName = `${categoryName} (Overflow ${nextN})`;
|
||||||
|
const lastCat = existingCategories[existingCategories.length - 1];
|
||||||
|
const position = (lastCat?.rawPosition ?? lastCat?.position ?? 0) + 1;
|
||||||
|
|
||||||
|
let newCat;
|
||||||
|
try {
|
||||||
|
newCat = await guild.channels.create({
|
||||||
|
name: newName,
|
||||||
|
type: ChannelType.GuildCategory,
|
||||||
|
position
|
||||||
|
});
|
||||||
|
} catch (createErr) {
|
||||||
|
console.error('getOrCreateTicketCategory: failed to create overflow category:', createErr);
|
||||||
|
throw createErr;
|
||||||
|
}
|
||||||
|
return newCat.id;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('getOrCreateTicketCategory:', err);
|
||||||
|
const fallback = guild.channels.cache.get(primaryCategoryId);
|
||||||
|
if (fallback?.type === ChannelType.GuildCategory) {
|
||||||
|
return primaryCategoryId;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an overflow category if it is empty and its name matches "${categoryName} (Overflow N)".
|
||||||
|
* Never deletes the primary category (exact name match).
|
||||||
|
* @param {import('discord.js').Guild} guild
|
||||||
|
* @param {string} categoryId
|
||||||
|
* @param {string} categoryName
|
||||||
|
*/
|
||||||
|
async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
|
||||||
|
try {
|
||||||
|
if (!guild || !categoryId) return;
|
||||||
|
const cached = guild.channels.cache.filter(c => c.parentId === categoryId);
|
||||||
|
if (cached.size !== 0) return;
|
||||||
|
|
||||||
|
let cat = guild.channels.cache.get(categoryId);
|
||||||
|
if (!cat) {
|
||||||
|
cat = await guild.channels.fetch(categoryId).catch(() => null);
|
||||||
|
}
|
||||||
|
if (!cat || cat.type !== ChannelType.GuildCategory) return;
|
||||||
|
if (cat.name === categoryName) return;
|
||||||
|
|
||||||
|
const escaped = escapeCategoryNameForRegex(categoryName);
|
||||||
|
const overflowRe = new RegExp(`^${escaped} \\(Overflow \\d+\\)$`);
|
||||||
|
if (!overflowRe.test(cat.name)) return;
|
||||||
|
|
||||||
|
await cat.delete().catch(deleteErr => {
|
||||||
|
console.error('cleanupEmptyOverflowCategory: delete failed:', deleteErr);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('cleanupEmptyOverflowCategory:', err);
|
||||||
}
|
}
|
||||||
return list[0] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTicketChannel(guild, ticketNumber, userId, subject) {
|
async function createTicketChannel(guild, ticketNumber, userId, subject) {
|
||||||
@@ -155,39 +274,47 @@ async function createTicketChannel(guild, ticketNumber, userId, subject) {
|
|||||||
}
|
}
|
||||||
return thread;
|
return thread;
|
||||||
} else {
|
} else {
|
||||||
const categoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
let parentId;
|
||||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
try {
|
||||||
if (!parentId) {
|
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||||
throw new Error('Ticket category not found or all categories full (50 channels max per category)');
|
} catch (e) {
|
||||||
|
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||||||
|
throw new Error('Ticket category not found or could not be allocated');
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await guild.channels.create({
|
let channel;
|
||||||
name: `ticket-${ticketNumber}`,
|
try {
|
||||||
type: ChannelType.GuildText,
|
channel = await guild.channels.create({
|
||||||
parent: parentId,
|
name: `ticket-${ticketNumber}`,
|
||||||
permissionOverwrites: [
|
type: ChannelType.GuildText,
|
||||||
{
|
parent: parentId,
|
||||||
id: guild.id,
|
permissionOverwrites: [
|
||||||
deny: [PermissionFlagsBits.ViewChannel]
|
{
|
||||||
},
|
id: guild.id,
|
||||||
{
|
deny: [PermissionFlagsBits.ViewChannel]
|
||||||
id: userId,
|
},
|
||||||
allow: [
|
{
|
||||||
PermissionFlagsBits.ViewChannel,
|
id: userId,
|
||||||
PermissionFlagsBits.SendMessages,
|
allow: [
|
||||||
PermissionFlagsBits.ReadMessageHistory
|
PermissionFlagsBits.ViewChannel,
|
||||||
]
|
PermissionFlagsBits.SendMessages,
|
||||||
},
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
{
|
]
|
||||||
id: CONFIG.ROLE_ID_TO_PING,
|
},
|
||||||
allow: [
|
{
|
||||||
PermissionFlagsBits.ViewChannel,
|
id: CONFIG.ROLE_ID_TO_PING,
|
||||||
PermissionFlagsBits.SendMessages,
|
allow: [
|
||||||
PermissionFlagsBits.ReadMessageHistory
|
PermissionFlagsBits.ViewChannel,
|
||||||
]
|
PermissionFlagsBits.SendMessages,
|
||||||
}
|
PermissionFlagsBits.ReadMessageHistory
|
||||||
]
|
]
|
||||||
});
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('guild.channels.create (createTicketChannel):', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
@@ -405,6 +532,8 @@ async function checkAutoUnclaim(client) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
getNextTicketNumber,
|
getNextTicketNumber,
|
||||||
pickTicketCategoryId,
|
pickTicketCategoryId,
|
||||||
|
getOrCreateTicketCategory,
|
||||||
|
cleanupEmptyOverflowCategory,
|
||||||
createDiscordTicketAsThread,
|
createDiscordTicketAsThread,
|
||||||
createEmailTicketAsThread,
|
createEmailTicketAsThread,
|
||||||
RENAME_WINDOW_MS,
|
RENAME_WINDOW_MS,
|
||||||
|
|||||||
Reference in New Issue
Block a user