diff --git a/.env.example b/.env.example index 6d55482..7378ebb 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/.env.test.example b/.env.test.example index 8bc0a11..f4ac341 100644 --- a/.env.test.example +++ b/.env.test.example @@ -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= diff --git a/README.md b/README.md index 3e416b7..d3759f0 100644 --- a/README.md +++ b/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 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` → `
`), 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 \.** - -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 ` | 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 ` | 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 ` | Add a user to the current ticket channel | -| `/remove ` | Remove a user from the current ticket channel | -| `/transfer ` | Transfer the ticket to another staff member | -| `/move ` | 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 ` | Send a saved response (autocomplete-enabled) | -| `/response create ` | Create a new saved response | -| `/response edit ` | Edit an existing saved response | -| `/response delete ` | 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 ` | Look up a user's account information | -| `/search ` | 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/) | --- diff --git a/config.js b/config.js index f9a8495..eacd3b2 100644 --- a/config.js +++ b/config.js @@ -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()) diff --git a/gmail-poll.js b/gmail-poll.js index 5042521..5c9eb61 100644 --- a/gmail-poll.js +++ b/gmail-poll.js @@ -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 } diff --git a/handlers/buttons.js b/handlers/buttons.js index 2ada5f8..6bb4939 100644 --- a/handlers/buttons.js +++ b/handlers/buttons.js @@ -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; diff --git a/handlers/commands.js b/handlers/commands.js index 768fa2e..e2de7f8 100644 --- a/handlers/commands.js +++ b/handlers/commands.js @@ -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() diff --git a/models.js b/models.js index b5092a8..f140407 100644 --- a/models.js +++ b/models.js @@ -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({ diff --git a/services/channelQueue.js b/services/channelQueue.js index 6913121..948747e 100644 --- a/services/channelQueue.js +++ b/services/channelQueue.js @@ -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) { diff --git a/services/tickets.js b/services/tickets.js index b127991..f7a6d20 100644 --- a/services/tickets.js +++ b/services/tickets.js @@ -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} + */ +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,