25 KiB
Broccolini Bot
A Node.js Discord bot that unifies Gmail, Discord, and MongoDB for support ticketing. Incoming emails become Discord ticket channels; staff messages in those channels are sent back to customers via Gmail. Discord-originated tickets (panels, context menu) live entirely in Discord. State is stored in MongoDB via Mongoose.
Built for game-server hosting support (Indifferent Broccoli), with game detection from email content, tiered escalation, optional per-staff notification channels (reply alerts with cooldown + unclaimed-ticket digests), saved responses, /tag categorization, claimer emoji in channel names (STAFF_EMOJIS), and automation (auto-close, reminders, auto-unclaim, claim timeout).
Jump to: Features · Quick start · Configuration · Staff notifications · Commands · Project layout
Table of contents
- Features
- Architecture
- Prerequisites
- Quick start
- Installation
- Configuration
- Staff notification channels & reply alerts
- Running the bot (test and Docker)
- Discord commands
- Ticket UI (buttons & modals)
- Tag & response system
- Panel system
- Channel renames & moves (rate limits)
- Project structure
- Database collections
- HTTP: healthcheck & optional API
- Gmail OAuth refresh token
- Documentation in
docs/ - Development & CI
- Troubleshooting
- References
- License
Features
Email → Discord
- Polls Gmail about every 30 seconds for new unread primary mail (
gmail-poll.js). - Creates a Discord text channel (or thread, per guild settings) per email ticket, with overflow category support when a category is full.
- Detects game from subject/body using
GAME_LISTand built-in aliases inconfig.js. - Posts welcome + action buttons (Close, Claim, Escalate / De-escalate where applicable).
Discord → Gmail
- For email-sourced tickets, staff messages in the ticket channel are forwarded to the customer via Gmail (threaded).
- Discord-only tickets (
gmailThreadIdprefixdiscord-/discord-msg-) do not use Gmail for replies; conversation stays in Discord.
Ticket management
- Claim / Unclaim via buttons (not slash commands); optional claim overwrite, claim timeout, and auto-unclaim.
- Priority (
low/normal/medium/high) with configurable emojis and/priority. - Escalation: tier 2 and tier 3 categories (separate IDs for email vs Discord where configured); slash
/escalateand in-channel buttons. - De-escalation one step at a time (
/deescalateor button). - Close with confirmation; force-close for admins.
- Transcripts posted to a configured channel; closure email for email tickets.
- Auto-close, inactivity reminders, auto-unclaim (all optional via env).
Staff notifications (optional)
/notification addcreates a dedicated text channel per staff member underSTAFF_NOTIFICATION_CATEGORY_IDand stores it inStaffNotification(MongoDB).- When a non-staff user replies in a ticket claimed by someone who has a notification channel, the bot posts an alert there (subject to per-ticket cooldown hours, configurable via
/notification setor admin/staffnotification). - A background job runs every 30 minutes and, if
UNCLAIMED_REMINDER_THRESHOLDSis set, posts unclaimed ticket digests to those same channels when tickets cross age thresholds. /notifydmtoggles optional DM alerts to the claimer on customer reply (separate from the notification channel); stored inStaffSettings.
See Staff notification channels.
Note: Older docs referred to per-staffer mirror channels driven by STAFF_CATEGORIES. In current config.js that map is deprecated and always empty, and createStaffChannel is not called from the claim flow—staffChannelId on tickets is effectively unused. Reply alerts use StaffNotification channels instead.
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
/apiwhen the relevant API key env vars are set (see.env.example).
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ BROCCOLINI BOT │
├─────────────────────────────────────────────────────────────────┤
│ Gmail (inbox) ──► gmail-poll.js ──► Discord ticket channels │
│ │ ▲ │
│ ▼ │ │
│ services/gmail.js ◄── handlers/messages.js │
│ services/tickets.js handlers/buttons.js │
│ services/channelQueue.js │
│ services/staffNotifications.js │
│ │ │
│ ▼ │
│ MongoDB (Mongoose) ◄── models.js │
│ │
│ Express: GET / → "Active" ; optional /api → routes/ │
└─────────────────────────────────────────────────────────────────┘
Typical email ticket lifecycle
- New unread mail → poll creates Discord channel +
Ticketdocument. - Staff reply in channel → message handler sends Gmail reply (email tickets only).
- Close confirmed → transcript, optional closure email, channel delete; DB marked closed.
Prerequisites
| Requirement | Notes |
|---|---|
| Node.js | 18+; Docker image uses 20 (Dockerfile). |
| npm | npm install locally; npm ci --omit=dev in Docker. |
| MongoDB | Atlas or self-hosted; MONGODB_URI required at startup. |
| Discord application | Bot token, application ID; intents: Message Content, Server Members; also Guilds + Guild Messages. |
| Google Cloud | Gmail API enabled; OAuth2 client ID/secret + refresh token for the support mailbox. |
Quick start
git clone <your-repo-url>
cd broccolini-bot
npm install
cp .env.example .env
- Fill Discord (
DISCORD_TOKENorDISCORD_BOT_TOKEN,DISCORD_APPLICATION_ID,DISCORD_GUILD_ID, categories,ROLE_ID_TO_PING, transcript/log channels). - Fill MongoDB (
MONGODB_URI). - Fill Google OAuth (
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,REFRESH_TOKEN,MY_EMAIL) — usenode get-refresh-token.jsonce if needed. - Run
npm start. - In Discord, use
/setupor 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. On Unix shells:npm run start:test(setsENV_FILE). On Windows PowerShell:$env:ENV_FILE='.env.test'; node broccolini-discord.js(or setENV_FILEin the environment your process manager uses).npm run test-mongodb:testhas the sameENV_FILEpattern. - 1Password CLI:
npm run start:1p/start:test:1pinject 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.
Configuration
Configuration is environment variables only, loaded in config.js as CONFIG. Names below match .env.example unless noted.
Discord (core)
| Variable | Required | Description |
|---|---|---|
DISCORD_TOKEN |
Yes* | Bot token (*or DISCORD_BOT_TOKEN, first non-empty wins after trim). |
DISCORD_APPLICATION_ID |
Yes | Used as CLIENT_ID for REST command registration. |
DISCORD_GUILD_ID |
Yes | Guild where slash commands are registered. |
TICKET_CATEGORY_ID |
Yes | Default category for email tickets (startup validates this). |
DISCORD_TICKET_CATEGORY_ID |
No | Category for Discord panel/context tickets (falls back to TICKET_CATEGORY_ID). |
EMAIL_THREAD_CHANNEL_ID / DISCORD_THREAD_CHANNEL_ID |
No | Parent text channels for thread-style tickets, when used. |
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS |
No | Comma-separated extra categories when the main is full (50 channels). |
DISCORD_TICKET_OVERFLOW_CATEGORY_IDS |
No | Same for Discord ticket category. |
ROLE_ID_TO_PING |
Yes | Support role pinged on new tickets; alias ROLE_TO_PING_ID in code. |
ADDITIONAL_STAFF_ROLES |
No | Extra role IDs treated as staff for commands. |
BLACKLISTED_ROLES |
No | Roles blocked from opening tickets. |
TRANSCRIPT_CHANNEL_ID |
No | Where transcripts are posted on close. |
LOGGING_CHANNEL_ID |
No | Ticket lifecycle logs. |
DEBUGGING_CHANNEL_ID |
No | Optional error/debug forwarding. |
BACKUP_EXPORT_CHANNEL_ID |
No | Target for /backup and /export. |
ACCOUNT_INFO_CHANNEL_ID |
No | Account info flows. |
Escalation categories
| Variable | Description |
|---|---|
EMAIL_ESCALATED_CATEGORY_ID |
Legacy fallback; alias ESCALATED_CATEGORY_ID. |
DISCORD_ESCALATED_CATEGORY_ID |
Discord fallback tier-2–style bucket. |
DISCORD_ESCALATED2_CHANNEL_ID |
Tier 2 placement for Discord tickets (or + fallback category). |
EMAIL_ESCALATED2_CHANNEL_ID |
Tier 2 for email tickets (env name says CHANNEL for legacy reasons). |
DISCORD_ESCALATED3_CHANNEL_ID |
Tier 3 Discord. |
EMAIL_ESCALATED3_CHANNEL_ID |
Tier 3 email. |
Slash /escalate and buttons require the appropriate tier IDs for non-thread channels (threads skip category moves).
Staff notifications, claimer display, admin
| Variable | Description |
|---|---|
STAFF_NOTIFICATION_CATEGORY_ID |
Category where /notification add creates per-staffer notification channels. |
STAFF_EMOJIS |
Comma-separated discordUserId:emoji pairs; used in channel name when a ticket is claimed. |
CLAIMER_EMOJI_FALLBACK |
Emoji if the claimer has no STAFF_EMOJIS entry. |
ADMIN_ID |
Discord user ID allowed to use /staffnotification (override cooldown for another member). |
UNCLAIMED_REMINDER_THRESHOLDS |
Comma-separated hours (e.g. 1,2,4); drives unclaimed ticket alerts into notification channels. |
Google / Gmail
| Variable | Required | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
Yes | OAuth2 client ID. |
GOOGLE_CLIENT_SECRET |
Yes | OAuth2 secret. |
REFRESH_TOKEN |
Yes | Long-lived refresh for the inbox account. |
MY_EMAIL |
Yes | Canonical support address (lowercased in config). |
MongoDB
| Variable | Required |
|---|---|
MONGODB_URI |
Yes |
Test: npm run test-mongodb (optionally with ENV_FILE / .env.test as above).
Server & optional HTTP API
| Variable | Default | Description |
|---|---|---|
DISCORD_ONLY_PORT |
5000 |
Express listen port (CONFIG.PORT). |
HEALTHCHECK_HOST |
(all interfaces) | e.g. 127.0.0.1 for local-only bind. |
Additional variables for mounting /api (API key, CORS, etc.) are listed in .env.example if you use that integration.
Messaging & branding
See .env.example for defaults: ESCALATION_MESSAGE ({support_name}), TICKET_WELCOME_MESSAGE, TICKET_CLAIMED_MESSAGE / TICKET_UNCLAIMED_MESSAGE ({staff_mention}, {staff_name}), DISCORD_CLOSE_MESSAGE, DISCORD_TRANSCRIPT_MESSAGE ({channel_name}, {email}, {date_opened}, {date_closed}), EMAIL_SIGNATURE (\n → <br>), embed color hex vars, button labels/emojis, SUPPORT_NAME, LOGO_URL.
Automation & limits
- Auto-close:
AUTO_CLOSE_ENABLED,AUTO_CLOSE_AFTER_HOURS,AUTO_CLOSE_MESSAGE. - Reminders:
REMINDER_ENABLED,REMINDER_AFTER_HOURS,REMINDER_MESSAGE({ping},{hours}). - Limits:
GLOBAL_TICKET_LIMIT,TICKET_LIMIT_PER_CATEGORY,RATE_LIMIT_TICKETS_PER_USER,RATE_LIMIT_WINDOW_MINUTES. - Claim:
ALLOW_CLAIM_OVERWRITE,AUTO_UNCLAIM_*,CLAIM_TIMEOUT_ENABLED,CLAIM_TIMEOUT_HOURS. - Priority:
PRIORITY_ENABLED,DEFAULT_PRIORITY,PRIORITY_*_EMOJI.
Game list
GAME_LIST=comma,separated,names — used for detection/normalization in email handling (plus aliases in config.js).
Thread-style tickets (legacy)
USE_THREADS, THREAD_PARENT_CHANNEL (see .env.example) — optional legacy paths; primary behavior is also governed by GuildSettings.emailRouting (/email-routing: thread | category).
Staff notification channels & reply alerts
When STAFF_NOTIFICATION_CATEGORY_ID is set:
/notification add(with a target member) creates a channel under that category and savesuserId→channelId+ default cooldown inStaffNotification./notification set hours:(1–6) updates the cooldown between reply alerts for that user’s claimed tickets (same ticket keys offgmailThreadId)./staffnotification(admin,ADMIN_ID) sets cooldown for another staff member.- On messageCreate, if the ticket has a
claimerIdand the author is not detected as havingROLE_ID_TO_PING,notifyStaffOfReplymay post in the claimer’s notification channel (respecting cooldown). - Every 30 minutes,
notifyAllStaffUnclaimedevaluates open tickets withclaimedBy: nullagainstUNCLAIMED_REMINDER_THRESHOLDSand posts to all configured notification channels (tracks sent thresholds on the ticket inunclaimedReminderssent).
/notifydm toggles StaffSettings.notifyDm for the invoking user; when enabled, claimers can also receive a DM on customer reply (in addition to any notification channel).
Running the bot (test and Docker)
npm start
# or
node broccolini-discord.js
Test / alternate env file: see Installation for ENV_FILE on Windows vs Unix.
npm run test-mongodb
Docker (see Dockerfile):
docker build -t broccolini-bot .
docker run --env-file .env -p 5000:5000 broccolini-bot
Ensure MONGODB_URI and Discord token are available inside the container. A sample docker-compose.yml exists—adjust ports and env_file for your host (do not copy production-specific bind addresses into new deployments without review).
Discord commands
Most commands require staff (ROLE_ID_TO_PING or ADDITIONAL_STAFF_ROLES). /help is available more broadly per registration.
| Command | Description |
|---|---|
/setup |
Guild setup wizard (panel, role, category, transcript channel, etc.). |
/panel |
Post a ticket Open button in a channel (optional type: thread / category / both; custom title/description). |
/email-routing |
Choose whether new email tickets create threads vs category channels (GuildSettings in DB). |
/escalate |
Required: level (Tier 2 or Tier 3), action (unclaim clears claimedBy + claimerId after escalation, keep preserves claim). |
/deescalate |
Step down one tier (tier 3 → 2 → normal). |
/notifydm |
setting: on / off — DM when a non-staff user replies in a ticket you claimed. |
/notification |
Subcommands: set (cooldown hours), add (create notification channel for a member). |
/staffnotification |
Admin only (ADMIN_ID); override another member’s notification cooldown. |
/add, /remove |
Add/remove user overwrites on the current ticket channel. |
/transfer |
Set claimedBy to another staff member (must have staff role). |
/move |
Move channel to another category (direct setParent). |
/force-close |
Close without button confirmation (still archives transcript best-effort). |
/topic |
Set Discord channel topic. |
/priority |
low / normal / medium / high. |
/tag |
Set ticket tag category from dropdown (choices from TICKET_TAGS in config.js). |
/response |
Subcommands: send, create, edit, delete, list (saved responses). |
/accountinfo |
Subcommands: email, discord. |
/search |
Search tickets by email, subject, or number. |
/stats |
Bot analytics snapshot. |
/backup, /export |
Post exports to BACKUP_EXPORT_CHANNEL_ID. |
/help |
In-bot command summary embed. |
Context menus
- Create Ticket From Message — opens a ticket prefilled from a message.
- View User Tickets — lists recent tickets for a user (by sender tag match).
Ticket UI (buttons & modals)
- Open ticket (panel): modal fields are account email, game, description.
- In ticket channels: Close, Claim/Unclaim, Escalate (tier choice), De-escalate as built in
utils/ticketComponents.js/handlers/buttons.js. - Email routing and tag delete confirmations use additional button custom IDs.
Tag & response system
/tag
Sets ticketTag from the fixed list in config.js (TICKET_TAGS). Channel naming may incorporate tag/priority emojis via ticket naming logic.
/response
Templates support variables such as {ticket.user}, {ticket.email}, {ticket.number}, {ticket.subject}, {staff.name}, {staff.mention}, {server.name}, {date}, {time} (see utils.js / handler docs).
Panel system
- Run
/paneltargeting a channel (and optional style: thread-only, category-only, or both buttons). - User clicks Open ticket → modal → bot creates thread or channel per configuration.
- Welcome embeds + action row are posted;
TicketstoresdiscordThreadId,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 (p-queue). If rename is blocked, staff see a message with a relative time to retry.
Project structure
broccolini-bot/
├── broccolini-discord.js # Entry: Discord client, Express, Gmail poll interval, jobs
├── config.js # Env → CONFIG (game lists, TICKET_TAGS, STAFF_EMOJIS map, …)
├── db-connection.js # Mongo connect + require models
├── models.js # Mongoose schemas (Ticket, Tag, StaffSettings, StaffNotification, …)
├── utils.js # Email/game helpers, template variables
├── utils/ticketComponents.js # Action row builders
├── gmail-poll.js # Ingest Gmail → Discord ticket creation
├── get-refresh-token.js # One-shot OAuth refresh token helper
├── commands/register.js # Slash + context menu registration (discord.js v14)
├── handlers/
│ ├── buttons.js # Claim/close/modals/escalate buttons, ticket create modal
│ ├── commands.js # Slash handlers, runEscalation/runDeescalation
│ ├── messages.js # Staff ↔ Gmail relay; notifydm; notification alerts
│ ├── accountinfo.js
│ ├── analytics.js
│ └── setup.js
├── services/
│ ├── gmail.js
│ ├── tickets.js # Auto-close, reminders, auto-unclaim, naming helpers
│ ├── channelQueue.js # enqueueRename / enqueueMove
│ ├── staffChannel.js # Legacy mirror helpers (unused in current claim flow)
│ ├── staffNotifications.js # Reply alerts + unclaimed reminders
│ ├── staffSettings.js # notifydm prefs
│ ├── guildSettings.js
│ └── debugLog.js
├── routes/ # Optional Express `/api` routes
├── api/ # Bot client accessor for HTTP layer
├── scripts/ # Maintenance / one-off utilities
├── docs/ # Deeper guides (setup, security, MongoDB, API notes)
├── Dockerfile
├── docker-compose.yml
├── package.json
└── .env.example / .env.test.example
Database collections
| Model / collection | Role |
|---|---|
| Ticket | Gmail thread id, Discord channel/thread id, status, priority, claim (claimedBy display label, claimerId), legacy staffChannelId, escalation tier, welcomeMessageId, ticketTag, unclaimedReminderssent, etc. |
| TicketCounter | Per-sender local counters (legacy paths). |
| Transcript | Links closed tickets to transcript message IDs. |
| Tag | Saved response name + content. |
| GuildSettings | e.g. emailRouting: thread | category. |
| StaffSettings | Per-user notifyDm (+ guildId, updatedAt). |
| StaffNotification | Per-user channelId, cooldownHours for reply/unclaimed alerts. |
| CloseRequest | Pending close workflow if used. |
| User, Host, DashboardMetrics, ErrorLog | Shared / website-era schemas in the same models.js file. |
HTTP: healthcheck & optional API
GET /→ plain textActive(intended for load balancers / DockerHEALTHCHECK)./api/*is registered only after the bot isreadyand the optional HTTP API is enabled via env (see.env.example). JSON body parsing is enabled; auth uses a Bearer token from configuration. Route definitions live underroutes/in this repo.
Gmail OAuth refresh token
node get-refresh-token.js
Requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in .env, and redirect URI http://localhost:3000/oauth2callback registered on the Google OAuth client. Paste the printed refresh token into .env as REFRESH_TOKEN.
Documentation in docs/
Index: docs/README.md. Highlights:
| Doc | Topic |
|---|---|
| ENV_AND_SECURITY.md | Secrets, test env, agent rules |
| MONGODB_SETUP.md | Database |
| QUICKSTART.md | First-time orientation |
| PROJECT_STRUCTURE.md | Layout (may overlap this README) |
| 1PASSWORD.md | 1Password CLI for npm run start:1p |
Development & CI
This repo includes .gitlab-ci.yml with GitLab SAST and secret detection templates. Adjust or extend stages in GitLab as needed for your fork.
Troubleshooting
| Symptom | Checks |
|---|---|
| Commands missing | Correct DISCORD_APPLICATION_ID + DISCORD_GUILD_ID; restart bot; Discord can take time to sync. |
| Gmail not ingesting | REFRESH_TOKEN, API enablement, inbox auth; regenerate token if revoked. |
| MongoDB errors | MONGODB_URI, 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. |
| Test script env on Windows | npm run start:test sets ENV_FILE Unix-style; use PowerShell ENV_FILE + node if the script fails. |
References
| Technology | Link |
|---|---|
| discord.js v14 | discord.js guide |
| Google APIs (Gmail) | googleapis Node |
| Mongoose | mongoosejs.com |
| Express | expressjs.com |
License
ISC