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)
|
||||
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
||||
|
||||
# Overflow categories when main hits 50 channels (comma-separated, optional)
|
||||
# EMAIL_TICKET_OVERFLOW_CATEGORY_IDS=
|
||||
# DISCORD_TICKET_OVERFLOW_CATEGORY_IDS=
|
||||
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
|
||||
TICKET_CATEGORY_NAME=Open Tickets
|
||||
TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
|
||||
TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
|
||||
|
||||
# Escalation categories (tier 2 and tier 3)
|
||||
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
|
||||
|
||||
@@ -12,22 +12,23 @@ DISCORD_GUILD_ID= # Test server ID
|
||||
|
||||
# --- Discord: Channel & category IDs (test server) ---
|
||||
# Ticket creation: set one or both; /panel and /email-routing choose behavior
|
||||
DISCORD_TICKET_CATEGORY_ID=
|
||||
TICKET_CATEGORY_ID=
|
||||
# DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
||||
# EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
||||
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels (test)
|
||||
TICKET_CATEGORY_ID= # Category for email-originated ticket channels (test)
|
||||
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
|
||||
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
|
||||
|
||||
# Overflow categories when main hits 50 channels (comma-separated, optional)
|
||||
# EMAIL_TICKET_OVERFLOW_CATEGORY_IDS=
|
||||
# DISCORD_TICKET_OVERFLOW_CATEGORY_IDS=
|
||||
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
|
||||
TICKET_CATEGORY_NAME=Open Tickets
|
||||
TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
|
||||
TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
|
||||
|
||||
# Escalation (optional for test)
|
||||
# DISCORD_ESCALATED_CATEGORY_ID=
|
||||
# EMAIL_ESCALATED_CATEGORY_ID= # legacy alias: ESCALATED_CATEGORY_ID
|
||||
DISCORD_ESCALATED2_CHANNEL_ID=
|
||||
DISCORD_ESCALATED3_CHANNEL_ID=
|
||||
EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 category ID (email); env name *_CHANNEL_* is legacy
|
||||
EMAIL_ESCALATED3_CHANNEL_ID=
|
||||
# Escalation categories (tier 2 and tier 3; optional for minimal test)
|
||||
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
|
||||
EMAIL_ESCALATED_CATEGORY_ID= # Fallback escalation category (email); legacy alias: ESCALATED_CATEGORY_ID
|
||||
DISCORD_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category/channel (Discord)
|
||||
DISCORD_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category/channel (Discord)
|
||||
EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category ID (email); env name *_CHANNEL_* is legacy
|
||||
EMAIL_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category ID (email)
|
||||
|
||||
# --- Logging, transcripts, and utility ---
|
||||
ROLE_ID_TO_PING=
|
||||
|
||||
779
README.md
779
README.md
@@ -1,39 +1,33 @@
|
||||
# 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)
|
||||
- [Architecture](#architecture)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick start](#quick-start)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Discord](#discord)
|
||||
- [Google OAuth2 / Gmail](#google-oauth2--gmail)
|
||||
- [MongoDB](#mongodb)
|
||||
- [Branding & Messages](#branding--messages)
|
||||
- [Automation](#automation)
|
||||
- [Ticket Limits & Permissions](#ticket-limits--permissions)
|
||||
- [Priority Levels](#priority-levels)
|
||||
- [Claiming Options](#claiming-options)
|
||||
- [Button & Embed Customization](#button--embed-customization)
|
||||
- [Running the Bot](#running-the-bot)
|
||||
- [Test Environment](#test-environment)
|
||||
- [Discord Commands](#discord-commands)
|
||||
- [Tag System](#tag-system)
|
||||
- [Panel System](#panel-system)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Database Schema](#database-schema)
|
||||
- [API Integrations](#api-integrations)
|
||||
- [Healthcheck](#healthcheck)
|
||||
- [Documentation](#documentation)
|
||||
- [Staff personal categories & mirror channels](#staff-personal-categories--mirror-channels)
|
||||
- [Running the bot](#running-the-bot-test-and-docker)
|
||||
- [Discord commands](#discord-commands)
|
||||
- [Ticket UI (buttons & modals)](#ticket-ui-buttons--modals)
|
||||
- [Tag & response system](#tag--response-system)
|
||||
- [Panel system](#panel-system)
|
||||
- [Channel renames & moves (rate limits)](#channel-renames--moves-rate-limits)
|
||||
- [Project structure](#project-structure)
|
||||
- [Database collections](#database-collections)
|
||||
- [HTTP: healthcheck & bOSScord API](#http-healthcheck--bosscord-api)
|
||||
- [Gmail OAuth refresh token](#gmail-oauth-refresh-token)
|
||||
- [Documentation in `docs/`](#documentation-in-docs)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [References](#references)
|
||||
- [License](#license)
|
||||
@@ -42,532 +36,425 @@ Built for game-server hosting support (Indifferent Broccoli), with game detectio
|
||||
|
||||
## Features
|
||||
|
||||
### Email-to-Discord Ticketing
|
||||
- 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)
|
||||
### Email → Discord
|
||||
|
||||
### Discord-to-Email Replies
|
||||
- Staff messages in a ticket channel are forwarded to the original sender via Gmail
|
||||
- Replies are threaded in Gmail so the sender sees a continuous conversation
|
||||
- Polls Gmail about every **30 seconds** for new mail.
|
||||
- Creates a **Discord text channel** per email ticket (with overflow category support when a category is full).
|
||||
- Detects **game** from subject/body using `GAME_LIST`.
|
||||
- Posts welcome + action **buttons** (Close, Claim, Escalate / De-escalate where applicable).
|
||||
|
||||
### Ticket Management
|
||||
- **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
|
||||
### Discord → Gmail
|
||||
|
||||
### Panel System
|
||||
- Deploy a "Open Ticket" button panel to any channel with `/panel`
|
||||
- Users click the button, fill out a modal form, and a ticket is created
|
||||
- For **email-sourced** tickets, staff messages in the ticket channel are **forwarded** to the customer via Gmail (threaded).
|
||||
- **Discord-only** tickets (`gmailThreadId` prefix `discord-` / `discord-msg-`) do not use Gmail for replies; conversation stays in Discord.
|
||||
|
||||
### Tag System (Saved Responses)
|
||||
- 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
|
||||
### Ticket management
|
||||
|
||||
### Account Info Lookup
|
||||
- `/accountinfo` searches website users by email or Discord ID
|
||||
- Results show linked servers, game details, and user metadata
|
||||
- **Claim / Unclaim** via buttons (not slash commands); optional claim overwrite and auto-unclaim.
|
||||
- **Priority** (`low` / `normal` / `medium` / `high`) with configurable emojis and `/priority`.
|
||||
- **Escalation**: tier 2 and tier 3 categories (separate IDs for email vs Discord where configured); slash `/escalate` and in-channel buttons.
|
||||
- **De-escalation** one step at a time (`/deescalate` or button).
|
||||
- **Close** with confirmation; **force-close** for admins.
|
||||
- **Transcripts** posted to a configured channel; closure email for email tickets.
|
||||
- **Auto-close**, **inactivity reminders**, **auto-unclaim** (all optional via env).
|
||||
|
||||
### Analytics & Logging
|
||||
- In-memory tracking of command usage, button clicks, and errors
|
||||
- `/stats` shows uptime, interaction counts, and error rate
|
||||
- Configurable logging channel for ticket lifecycle events
|
||||
### Staff personal categories (optional)
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ BROCCOLINI BOT │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────┐ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Gmail │─────>│ gmail-poll.js │─────>│ Discord │ │
|
||||
│ │ (inbox) │ │ (every 30s) │ │ (ticket channel)│ │
|
||||
│ └───────────┘ └───────┬────────┘ └───────▲──────────┘ │
|
||||
│ │ │ │
|
||||
│ v │ │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ services/ │ │ handlers/ │ │
|
||||
│ │ gmail.js │<────>│ messages.js │ │
|
||||
│ │ tickets.js │ │ buttons.js │ │
|
||||
│ │ guildSettings │ │ commands.js │ │
|
||||
│ └───────┬────────┘ └──────────────────┘ │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ MongoDB │ │ Express │ │
|
||||
│ │ (Mongoose) │ │ (healthcheck) │ │
|
||||
│ └────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ Events: │
|
||||
│ ready → Connect DB, register commands, start jobs │
|
||||
│ interactionCreate → Buttons, slash commands, modals, menus │
|
||||
│ messageCreate → Discord replies → Gmail │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BROCCOLINI BOT │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Gmail (inbox) ──► gmail-poll.js ──► Discord ticket channels │
|
||||
│ │ ▲ │
|
||||
│ ▼ │ │
|
||||
│ services/gmail.js ◄── handlers/messages.js │
|
||||
│ services/tickets.js handlers/buttons.js │
|
||||
│ services/channelQueue.js handlers/commands.js│
|
||||
│ services/staffChannel.js │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ MongoDB (Mongoose) ◄── models.js │
|
||||
│ │
|
||||
│ Express: GET / → "Active" ; optional /api → routes/bosscord.js │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Ticket lifecycle:**
|
||||
**Typical email ticket lifecycle**
|
||||
|
||||
1. **Inbound email** -- Gmail poll detects a new unread message, creates a Discord channel and a MongoDB record.
|
||||
2. **Staff reply** -- A message in the Discord ticket channel is forwarded to the sender via Gmail.
|
||||
3. **Close** -- A transcript is generated, a closure email is sent, and the Discord channel is deleted.
|
||||
1. New unread mail → poll creates Discord channel + `Ticket` document.
|
||||
2. Staff reply in channel → message handler sends Gmail reply (email tickets only).
|
||||
3. Close confirmed → transcript, optional closure email, channel delete; DB marked closed.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| Node.js | ≥ 18.x |
|
||||
| npm | ≥ 9.x |
|
||||
| MongoDB | ≥ 5.x (Atlas or self-hosted) |
|
||||
| Requirement | Notes |
|
||||
|-------------|--------|
|
||||
| **Node.js** | **18+** recommended (Dockerfile uses 20). |
|
||||
| **npm** | Install dependencies with `npm install`. |
|
||||
| **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
|
||||
|
||||
Single-level repo: all commands run from the repo root. Create `.env` in the repo root (copy from `.env.example`).
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd broccolini-bot
|
||||
npm install
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
> **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
|
||||
### Discord (core)
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `DISCORD_TOKEN` | Yes | Bot token from the Discord Developer Portal |
|
||||
| `DISCORD_GUILD_ID` | Yes | Server (guild) ID where the bot operates |
|
||||
| `DISCORD_APPLICATION_ID` | Yes | Application ID for registering slash commands |
|
||||
| `TICKET_CATEGORY_ID` | Yes | Channel category ID where email ticket channels are created |
|
||||
| `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 tickets (defaults to `TICKET_CATEGORY_ID`) |
|
||||
| `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated category IDs; used when main Discord ticket category has 50 channels |
|
||||
| `ROLE_ID_TO_PING` | Yes | Role ID to ping when a new ticket arrives |
|
||||
| `TRANSCRIPT_CHANNEL_ID` | No | Channel ID for posting ticket transcripts |
|
||||
| `LOGGING_CHANNEL_ID` | No | Channel ID for lifecycle log messages |
|
||||
| `DEBUGGING_CHANNEL_ID` | No | Channel ID for error logs (escalate, deescalate, email-routing, Gmail poll, etc.) |
|
||||
| `BACKUP_EXPORT_CHANNEL_ID` | No | Channel ID where `/backup` and `/export` post ticket dump files |
|
||||
| `ACCOUNT_INFO_CHANNEL_ID` | No | Channel ID for account info lookups (and `/accountinfo` visibility) |
|
||||
| `EMAIL_ESCALATED_CATEGORY_ID` | No | Category ID for escalated email tickets (tier 2+) |
|
||||
| `DISCORD_ESCALATED_CATEGORY_ID` | No | Category ID for escalated Discord-origin tickets |
|
||||
| `ESCALATION_MESSAGE` | No | Message sent when a ticket is escalated (supports `{support_name}`) |
|
||||
|----------|----------|-------------|
|
||||
| `DISCORD_TOKEN` | Yes* | Bot token (*or `DISCORD_BOT_TOKEN`, first non-empty wins after trim). |
|
||||
| `DISCORD_APPLICATION_ID` | Yes | Used as `CLIENT_ID` for REST command registration. |
|
||||
| `DISCORD_GUILD_ID` | Yes | Guild where slash commands are registered. |
|
||||
| `TICKET_CATEGORY_ID` | Yes | Default category for **email** tickets (startup also validates this). |
|
||||
| `DISCORD_TICKET_CATEGORY_ID` | No | Category for **Discord** panel/context tickets (falls back to `TICKET_CATEGORY_ID`). |
|
||||
| `EMAIL_THREAD_CHANNEL_ID` / `DISCORD_THREAD_CHANNEL_ID` | No | Parent **text** channels for thread-style tickets, if you use threads. |
|
||||
| `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated extra categories when the main is full (50 channels). |
|
||||
| `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS` | No | Same for Discord ticket category. |
|
||||
| `ROLE_ID_TO_PING` | Yes | Support role pinged on new tickets; aliases include `ROLE_TO_PING_ID` in code paths. |
|
||||
| `ADDITIONAL_STAFF_ROLES` | No | Extra role IDs treated as staff for commands. |
|
||||
| `BLACKLISTED_ROLES` | No | Roles blocked from opening tickets. |
|
||||
| `TRANSCRIPT_CHANNEL_ID` | No | Where transcripts are posted on close. |
|
||||
| `LOGGING_CHANNEL_ID` | No | Ticket lifecycle logs. |
|
||||
| `DEBUGGING_CHANNEL_ID` | No | Optional error/debug forwarding. |
|
||||
| `BACKUP_EXPORT_CHANNEL_ID` | No | Target for `/backup` and `/export`. |
|
||||
| `ACCOUNT_INFO_CHANNEL_ID` | No | Account info flows. |
|
||||
|
||||
### 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 |
|
||||
|---|---|---|
|
||||
| `GOOGLE_CLIENT_ID` | Yes | OAuth2 Client ID from Google Cloud Console |
|
||||
| `GOOGLE_CLIENT_SECRET` | Yes | OAuth2 Client Secret |
|
||||
| `REFRESH_TOKEN` | Yes | OAuth2 Refresh Token for the support inbox |
|
||||
| `MY_EMAIL` | Yes | The support email address (e.g. `support@example.com`) |
|
||||
|----------|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Yes | OAuth2 client ID. |
|
||||
| `GOOGLE_CLIENT_SECRET` | Yes | OAuth2 secret. |
|
||||
| `REFRESH_TOKEN` | Yes | Long-lived refresh for the inbox account. |
|
||||
| `MY_EMAIL` | Yes | Canonical support address (lowercased in config). |
|
||||
|
||||
### MongoDB
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `MONGODB_URI` | Yes | MongoDB connection string (e.g. `mongodb+srv://user:pass@cluster/dbname`) |
|
||||
| Variable | Required |
|
||||
|----------|----------|
|
||||
| `MONGODB_URI` | Yes |
|
||||
|
||||
### Branding & Messages
|
||||
Test: `npm run test-mongodb` (or with `ENV_FILE=.env.test`).
|
||||
|
||||
### Server & optional API
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SUPPORT_NAME` | -- | Display name for the support system |
|
||||
| `LOGO_URL` | -- | URL to the logo shown in embeds |
|
||||
| `EMAIL_SIGNATURE` | -- | HTML signature appended to outgoing emails (use `\n` for line breaks) |
|
||||
| `TICKET_CLOSE_SUBJECT_PREFIX` | `[Resolved]` | Prefix added to the subject of closure emails |
|
||||
| `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 |
|
||||
|----------|---------|-------------|
|
||||
| `DISCORD_ONLY_PORT` | `5000` | Express listen port (`CONFIG.PORT`). |
|
||||
| `HEALTHCHECK_HOST` | *(all interfaces)* | e.g. `127.0.0.1` for local-only bind. |
|
||||
| `BOSSCORD_API_KEY` | — | If set, mounts **`/api`** (bOSScord); use a strong random key. |
|
||||
| `BOSSCORD_CORS_ORIGIN` | `*` | Optional CORS for the API. |
|
||||
|
||||
### Automation
|
||||
### Messaging & branding
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `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) |
|
||||
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`.
|
||||
|
||||
### Ticket Limits & Permissions
|
||||
### Automation & limits
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GLOBAL_TICKET_LIMIT` | `5` | Maximum concurrent open tickets globally |
|
||||
| `TICKET_LIMIT_PER_CATEGORY` | `3` | Maximum tickets per category |
|
||||
| `RATE_LIMIT_TICKETS_PER_USER` | `0` | Max tickets a user can create per window (0 = disabled) |
|
||||
| `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 |
|
||||
- **Auto-close:** `AUTO_CLOSE_ENABLED`, `AUTO_CLOSE_AFTER_HOURS`, `AUTO_CLOSE_MESSAGE`.
|
||||
- **Reminders:** `REMINDER_ENABLED`, `REMINDER_AFTER_HOURS`, `REMINDER_MESSAGE` (`{ping}`, `{hours}`).
|
||||
- **Limits:** `GLOBAL_TICKET_LIMIT`, `TICKET_LIMIT_PER_CATEGORY`, `RATE_LIMIT_TICKETS_PER_USER`, `RATE_LIMIT_WINDOW_MINUTES`.
|
||||
- **Claim:** `ALLOW_CLAIM_OVERWRITE`, `AUTO_UNCLAIM_*`, `CLAIM_TIMEOUT_*`.
|
||||
- **Priority:** `PRIORITY_ENABLED`, `DEFAULT_PRIORITY`, `PRIORITY_*_EMOJI`.
|
||||
|
||||
### Priority Levels
|
||||
### Game list
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `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, ...
|
||||
```
|
||||
`GAME_LIST=comma,separated,names` — used for detection/normalization in email handling.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
# Start the bot
|
||||
npm start
|
||||
|
||||
# Or directly
|
||||
# or
|
||||
node broccolini-discord.js
|
||||
```
|
||||
|
||||
On startup the bot will:
|
||||
|
||||
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:
|
||||
**Test:**
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
```bash
|
||||
docker build -t broccolini-bot .
|
||||
docker run --env-file .env -p 5000:5000 broccolini-bot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
Ensure `MONGODB_URI` and Discord token are available inside the container.
|
||||
|
||||
---
|
||||
|
||||
## 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-discord.js # Entry point - initializes bot, events, and jobs
|
||||
├── config.js # Environment variable loading and CONFIG export
|
||||
├── db-connection.js # MongoDB connection with reconnect logic
|
||||
├── models.js # Mongoose schemas (Ticket, User, Tag, etc.)
|
||||
├── utils.js # Text processing, game detection, template vars
|
||||
├── gmail-poll.js # Gmail polling loop and ticket creation
|
||||
├── game-options.json # Game configuration data
|
||||
│
|
||||
├── commands/
|
||||
│ └── register.js # Slash command and context menu registration
|
||||
│
|
||||
├── broccolini-discord.js # Entry: Discord client, Express, Gmail poll interval, jobs
|
||||
├── config.js # Env → CONFIG (+ STAFF_CATEGORIES map, game lists)
|
||||
├── db-connection.js # Mongo connect + require models
|
||||
├── models.js # Mongoose schemas (Ticket, Tag, StaffSettings, …)
|
||||
├── utils.js # Email/game helpers, template variables
|
||||
├── utils/ticketComponents.js # Action row builders
|
||||
├── gmail-poll.js # Ingest Gmail → Discord ticket creation
|
||||
├── get-refresh-token.js # One-shot OAuth refresh token helper
|
||||
├── commands/register.js # Slash + context menu registration (discord.js v14)
|
||||
├── handlers/
|
||||
│ ├── accountinfo.js # /accountinfo command and button handler
|
||||
│ ├── analytics.js # In-memory analytics and error tracking
|
||||
│ ├── buttons.js # Button interactions (claim, close, priority, etc.)
|
||||
│ ├── commands.js # All slash command handlers
|
||||
│ ├── messages.js # Discord → Gmail reply forwarding
|
||||
│ └── setup.js # Guild setup / configuration flow
|
||||
│
|
||||
│ ├── buttons.js # Claim/close/modals/escalate buttons, ticket create modal
|
||||
│ ├── commands.js # Slash handlers, runEscalation/runDeescalation
|
||||
│ ├── messages.js # Staff ↔ Gmail relay; mirror pings + notifydm
|
||||
│ ├── accountinfo.js
|
||||
│ ├── analytics.js
|
||||
│ └── setup.js
|
||||
├── services/
|
||||
│ ├── debugLog.js # Structured debug logging
|
||||
│ ├── gmail.js # Gmail OAuth2, send replies, closure emails
|
||||
│ ├── guildSettings.js # Guild-specific settings (DB + cache)
|
||||
│ └── tickets.js # Ticket CRUD, auto-close, reminders, auto-unclaim
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── backup-env.js # Copy .env to .env.backup
|
||||
│ └── test-mongodb.js # MongoDB connection test
|
||||
│
|
||||
├── docs/ # Additional documentation (QUICKSTART, MONGODB_SETUP, ENV_AND_SECURITY, etc.)
|
||||
├── .env # Environment variables (not committed)
|
||||
│ ├── gmail.js
|
||||
│ ├── tickets.js # Auto-close, reminders, auto-unclaim, naming helpers
|
||||
│ ├── channelQueue.js # enqueueRename / enqueueMove
|
||||
│ ├── staffChannel.js # Mirror create/ping/move/delete
|
||||
│ ├── staffSettings.js # notifydm prefs
|
||||
│ ├── guildSettings.js
|
||||
│ └── debugLog.js
|
||||
├── routes/bosscord.js # Optional /api routes
|
||||
├── api/bosscordClient.js
|
||||
├── scripts/ # Maintenance / one-off utilities
|
||||
├── docs/ # Deeper guides (setup, security, MongoDB, API notes)
|
||||
├── Dockerfile
|
||||
├── package.json
|
||||
└── package-lock.json
|
||||
└── .env.example / .env.test.example
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
## Database collections
|
||||
|
||||
The bot uses MongoDB via Mongoose. Key collections:
|
||||
|
||||
| Collection | Purpose |
|
||||
|---|---|
|
||||
| `Ticket` | Core ticket data: Gmail thread ID, Discord channel ID, sender info, status, priority, claimed-by, timestamps |
|
||||
| `TicketCounter` | Auto-incrementing ticket numbers per sender |
|
||||
| `Transcript` | Transcript message references for closed tickets |
|
||||
| `Tag` | Saved response templates (name, content, creator) |
|
||||
| `CloseRequest` | Tracks pending close confirmations |
|
||||
| `User` | Website user accounts (email, Discord ID, linked servers) |
|
||||
| `Host` | Game server/host metadata and metrics |
|
||||
| `DashboardMetrics` | Aggregated dashboard statistics |
|
||||
| `ErrorLog` | Persisted error records |
|
||||
| Model / collection | Role |
|
||||
|--------------------|------|
|
||||
| **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). |
|
||||
| **Transcript** | Links closed tickets to transcript message IDs. |
|
||||
| **Tag** | Saved response name + content. |
|
||||
| **GuildSettings** | e.g. `emailRouting`: `thread` \| `category`. |
|
||||
| **StaffSettings** | Per-user `notifyDm` (+ `guildId`, `updatedAt`). |
|
||||
| **CloseRequest** | Pending close workflow if used. |
|
||||
| **User**, **Host**, **DashboardMetrics**, **ErrorLog** | Shared / website-era schemas in the same `models.js` file. |
|
||||
|
||||
---
|
||||
|
||||
## API Integrations
|
||||
## HTTP: healthcheck & bOSScord API
|
||||
|
||||
### Gmail API
|
||||
|
||||
- **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.
|
||||
- **`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.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|-----|-------------|
|
||||
| [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 |
|
||||
| [PROJECT_STRUCTURE](docs/setup/PROJECT_STRUCTURE.md) | File and directory layout |
|
||||
| [PROPOSAL](docs/features/PROPOSAL.md) | Roadmap and possible next steps |
|
||||
| [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 |
|
||||
| [DISCORD_API_IMPROVEMENTS](docs/api/DISCORD_API_IMPROVEMENTS.md) · [DISCORD_API_VALIDATION](docs/api/DISCORD_API_VALIDATION.md) | Discord API implementation notes |
|
||||
Requires `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env`, and redirect URI **`http://localhost:3000/oauth2callback`** registered on the Google OAuth client. Paste the printed refresh token into `.env` as `REFRESH_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
## Documentation in `docs/`
|
||||
|
||||
Index: **[docs/README.md](docs/README.md)**. Highlights:
|
||||
|
||||
| Doc | Topic |
|
||||
|-----|--------|
|
||||
| [ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md) | Secrets, test env, agent rules |
|
||||
| [MONGODB_SETUP.md](docs/setup/MONGODB_SETUP.md) | Database |
|
||||
| [QUICKSTART.md](docs/setup/QUICKSTART.md) | First-time orientation |
|
||||
| [PROJECT_STRUCTURE.md](docs/setup/PROJECT_STRUCTURE.md) | Layout (may overlap this README) |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Slash commands not appearing in Discord
|
||||
|
||||
- Commands are registered per-guild on startup. Wait up to one hour for Discord to propagate.
|
||||
- Verify `DISCORD_APPLICATION_ID` and `DISCORD_GUILD_ID` are correct.
|
||||
- Restart the bot.
|
||||
|
||||
### Gmail polling not working
|
||||
|
||||
- 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.
|
||||
| Symptom | Checks |
|
||||
|---------|--------|
|
||||
| **Commands missing** | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; **restart** bot; Discord can take time to sync. |
|
||||
| **Gmail not ingesting** | `REFRESH_TOKEN`, API enablement, inbox auth; regenerate token if revoked. |
|
||||
| **MongoDB errors** | `MONGODB_URI`, Atlas IP allowlist, `npm run test-mongodb`. |
|
||||
| **Channels not creating** | Bot **Manage Channels** in ticket categories; category not full (50) unless overflow set. |
|
||||
| **Modal / button no response** | Intents + permissions; bot online; check `DEBUGGING_CHANNEL_ID` / console. |
|
||||
| **Renames “too quickly”** | Discord rename cooldown; wait for channel queue / timestamp in bot message. |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
This project builds on or references the following:
|
||||
|
||||
| Technology | Description | Links |
|
||||
|------------|-------------|--------|
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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) |
|
||||
| Technology | Link |
|
||||
|------------|------|
|
||||
| discord.js v14 | [discord.js guide](https://discordjs.guide/) |
|
||||
| Google APIs (Gmail) | [googleapis Node](https://github.com/googleapis/google-api-nodejs-client) |
|
||||
| Mongoose | [mongoosejs.com](https://mongoosejs.com/) |
|
||||
| Express | [expressjs.com](https://expressjs.com/) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ const CONFIG = {
|
||||
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
|
||||
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
|
||||
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
|
||||
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
|
||||
TICKET_T2_CATEGORY_NAME: process.env.TICKET_T2_CATEGORY_NAME || 'Tier 2 Escalated Tickets',
|
||||
TICKET_T3_CATEGORY_NAME: process.env.TICKET_T3_CATEGORY_NAME || 'Tier 3 Escalated Tickets',
|
||||
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
|
||||
@@ -19,7 +19,7 @@ const {
|
||||
getFormattedDate
|
||||
} = require('./utils');
|
||||
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 { logError } = require('./services/debugLog');
|
||||
|
||||
@@ -125,8 +125,9 @@ async function poll(client) {
|
||||
.select('gmailThreadId discordThreadId status')
|
||||
.lean();
|
||||
|
||||
let ticketChan = null;
|
||||
let isReopened = false;
|
||||
let ticketChan = null;
|
||||
let parentCategoryIdForTicket = null;
|
||||
let isReopened = false;
|
||||
|
||||
if (existing && existing.discordThreadId) {
|
||||
ticketChan = await guild.channels
|
||||
@@ -166,17 +167,24 @@ async function poll(client) {
|
||||
const routing = await getEmailRouting(guild.id);
|
||||
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
|
||||
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
|
||||
parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null;
|
||||
} else {
|
||||
const emailCategoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
||||
const parentId = pickTicketCategoryId(guild, emailCategoryIds);
|
||||
if (!parentId) {
|
||||
throw new Error('Email ticket category not found or all categories full (50 channels max)');
|
||||
const parentId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.TICKET_CATEGORY_ID,
|
||||
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) {
|
||||
console.error('Channel create error (payload):', {
|
||||
@@ -297,7 +305,8 @@ async function poll(client) {
|
||||
status: 'open',
|
||||
ticketNumber: number,
|
||||
priority: defaultPriority,
|
||||
lastActivity: now
|
||||
lastActivity: now,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
}
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
|
||||
@@ -16,7 +16,7 @@ const {
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
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 { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
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 });
|
||||
}
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, 1, 'Escalated via button (Tier 2)');
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
@@ -194,10 +197,13 @@ async function handleButton(interaction) {
|
||||
return interaction.reply({ content: 'Tier 3 (ESCALATED3) is not configured for this ticket type.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, 2, 'Escalated via button (Tier 3)');
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
@@ -209,10 +215,13 @@ async function handleButton(interaction) {
|
||||
return interaction.reply({ content: 'This ticket is not escalated.', ephemeral: true });
|
||||
}
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await runDeescalation(interaction, ticket);
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
@@ -569,10 +578,20 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
});
|
||||
}
|
||||
|
||||
const parentCatId = ticket.parentCategoryId;
|
||||
const guildRef = interaction.guild;
|
||||
|
||||
setTimeout(
|
||||
() => interaction.channel.delete().catch(() => {}),
|
||||
5000
|
||||
);
|
||||
setTimeout(() => {
|
||||
(async () => {
|
||||
if (parentCatId && guildRef) {
|
||||
await cleanupEmptyOverflowCategory(guildRef, parentCatId, CONFIG.TICKET_CATEGORY_NAME);
|
||||
}
|
||||
})();
|
||||
}, 6000);
|
||||
} catch (e) {
|
||||
console.error('Close ticket error:', e);
|
||||
}
|
||||
@@ -606,9 +625,11 @@ async function handleTicketModal(interaction) {
|
||||
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
|
||||
|
||||
let channel;
|
||||
let parentCategoryIdForTicket = null;
|
||||
if (useThread && CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||
try {
|
||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, interaction.user.id);
|
||||
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
||||
} catch (err) {
|
||||
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.');
|
||||
@@ -616,27 +637,39 @@ async function handleTicketModal(interaction) {
|
||||
} 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.');
|
||||
} else {
|
||||
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
||||
if (!parentId) {
|
||||
return interaction.editReply('Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(
|
||||
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}`;
|
||||
@@ -651,7 +684,8 @@ async function handleTicketModal(interaction) {
|
||||
status: 'open',
|
||||
ticketNumber,
|
||||
priority,
|
||||
lastActivity: now
|
||||
lastActivity: now,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
});
|
||||
|
||||
const displayName = interaction.member?.displayName || interaction.user.username;
|
||||
|
||||
@@ -13,7 +13,7 @@ const {
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
||||
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 { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { getEmailRouting } = require('../services/guildSettings');
|
||||
@@ -118,7 +118,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
const pendingEmbed = new EmbedBuilder()
|
||||
.setDescription('Ticket will be escalated in a few seconds.')
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
await interaction.reply({ content: null, embeds: [pendingEmbed] });
|
||||
await interaction.editReply({ embeds: [pendingEmbed] });
|
||||
|
||||
const creatorId = isDiscordTicket
|
||||
? (ticket.gmailThreadId.split('-').pop() || '').trim()
|
||||
@@ -228,10 +228,7 @@ async function runDeescalation(interaction, ticket) {
|
||||
.setColor(0x00BFFF)
|
||||
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
|
||||
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
|
||||
await interaction.reply({
|
||||
embeds: [deescalateEmbed],
|
||||
ephemeral: true
|
||||
});
|
||||
await interaction.editReply({ embeds: [deescalateEmbed] });
|
||||
|
||||
const logChan = await interaction.client.channels.fetch(CONFIG.LOG_CHAN).catch(() => null);
|
||||
if (logChan) {
|
||||
@@ -316,6 +313,7 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.deferReply();
|
||||
await runEscalation(interaction, ticket, nextTier, reason);
|
||||
if (action === 'unclaim') {
|
||||
await Ticket.updateOne(
|
||||
@@ -325,7 +323,9 @@ async function handleCommand(interaction) {
|
||||
}
|
||||
} catch (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 {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await runDeescalation(interaction, ticket);
|
||||
} catch (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;
|
||||
|
||||
let channel;
|
||||
let parentCategoryIdForTicket = null;
|
||||
if (CONFIG.DISCORD_THREAD_CHANNEL_ID) {
|
||||
try {
|
||||
channel = await createDiscordTicketAsThread(guild, ticketNumber, message.author.id);
|
||||
parentCategoryIdForTicket = channel.parent?.parentId ?? null;
|
||||
} catch (err) {
|
||||
console.error('Discord ticket thread create (from message) failed:', err.message);
|
||||
return interaction.editReply('❌ Could not create ticket thread. Check DISCORD_THREAD_CHANNEL_ID.');
|
||||
}
|
||||
} else {
|
||||
const categoryIds = [CONFIG.DISCORD_TICKET_CATEGORY_ID, ...(CONFIG.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
||||
if (!parentId) {
|
||||
return interaction.editReply('❌ Discord ticket category not found or all categories full (50 channels max). Contact an administrator.');
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(
|
||||
guild,
|
||||
CONFIG.DISCORD_TICKET_CATEGORY_ID,
|
||||
CONFIG.TICKET_CATEGORY_NAME
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('getOrCreateTicketCategory (context menu ticket):', err);
|
||||
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
|
||||
}
|
||||
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}`;
|
||||
@@ -1086,7 +1103,8 @@ async function handleContextMenu(interaction) {
|
||||
status: 'open',
|
||||
ticketNumber,
|
||||
priority: 'normal',
|
||||
lastActivity: now
|
||||
lastActivity: now,
|
||||
parentCategoryId: parentCategoryIdForTicket
|
||||
});
|
||||
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
|
||||
@@ -814,7 +814,8 @@ mongoose.model('Ticket', new mongoose.Schema({
|
||||
reminderSent: { type: Boolean, default: false },
|
||||
welcomeMessageId: String,
|
||||
claimerId: String,
|
||||
staffChannelId: String
|
||||
staffChannelId: String,
|
||||
parentCategoryId: String
|
||||
}));
|
||||
|
||||
mongoose.model('TicketCounter', new mongoose.Schema({
|
||||
|
||||
@@ -10,7 +10,19 @@ const channelQueue = new PQueue({
|
||||
});
|
||||
|
||||
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) {
|
||||
|
||||
@@ -46,8 +46,15 @@ function makeTicketName({ escalated, claimed }, ticket, guild) {
|
||||
|
||||
async function canRename(ticket) {
|
||||
const now = Date.now();
|
||||
const windowStart = (ticket.renameWindowStart && new Date(ticket.renameWindowStart).getTime()) || 0;
|
||||
let count = ticket.renameCount || 0;
|
||||
const fresh = await Ticket.findOne({ gmailThreadId: ticket.gmailThreadId })
|
||||
.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) {
|
||||
await Ticket.updateOne(
|
||||
@@ -59,18 +66,28 @@ async function canRename(ticket) {
|
||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||
}
|
||||
|
||||
const remaining = RENAME_LIMIT - count;
|
||||
if (remaining <= 0) {
|
||||
if (count >= RENAME_LIMIT) {
|
||||
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
|
||||
return { ok: false, remaining: 0, waitMs };
|
||||
}
|
||||
|
||||
await Ticket.updateOne(
|
||||
const updated = await Ticket.findOneAndUpdate(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $inc: { renameCount: 1 } }
|
||||
);
|
||||
ticket.renameCount = count + 1;
|
||||
return { ok: true, remaining: RENAME_LIMIT - (count + 1), waitMs: 0 };
|
||||
{ $inc: { renameCount: 1 } },
|
||||
{ returnDocument: 'after' }
|
||||
)
|
||||
.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) {
|
||||
@@ -109,22 +126,124 @@ function checkTicketCreationRateLimit(userId) {
|
||||
|
||||
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.
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {string[]} categoryIds [mainId, ...overflowIds]
|
||||
* @returns {string|null} category id to use as parent, or null
|
||||
* @deprecated Use getOrCreateTicketCategory instead.
|
||||
* @returns {null}
|
||||
*/
|
||||
function pickTicketCategoryId(guild, categoryIds) {
|
||||
if (!guild || !Array.isArray(categoryIds)) return null;
|
||||
const list = categoryIds.filter(Boolean);
|
||||
for (const id of list) {
|
||||
const cat = guild.channels.cache.get(id);
|
||||
if (!cat || cat.type !== ChannelType.GuildCategory) continue;
|
||||
const count = guild.channels.cache.filter(c => c.parentId === id).size;
|
||||
if (count < CHANNELS_PER_CATEGORY_LIMIT) return id;
|
||||
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
|
||||
return null;
|
||||
}
|
||||
|
||||
function countChannelsInCategory(guild, categoryId) {
|
||||
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
@@ -155,39 +274,47 @@ async function createTicketChannel(guild, ticketNumber, userId, subject) {
|
||||
}
|
||||
return thread;
|
||||
} else {
|
||||
const categoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
||||
if (!parentId) {
|
||||
throw new Error('Ticket category not found or all categories full (50 channels max per category)');
|
||||
let parentId;
|
||||
try {
|
||||
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
|
||||
} catch (e) {
|
||||
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
|
||||
throw new Error('Ticket category not found or could not be allocated');
|
||||
}
|
||||
|
||||
const channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: userId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
let channel;
|
||||
try {
|
||||
channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: userId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('guild.channels.create (createTicketChannel):', e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
@@ -405,6 +532,8 @@ async function checkAutoUnclaim(client) {
|
||||
module.exports = {
|
||||
getNextTicketNumber,
|
||||
pickTicketCategoryId,
|
||||
getOrCreateTicketCategory,
|
||||
cleanupEmptyOverflowCategory,
|
||||
createDiscordTicketAsThread,
|
||||
createEmailTicketAsThread,
|
||||
RENAME_WINDOW_MS,
|
||||
|
||||
Reference in New Issue
Block a user