22 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 staff “mirror” channels per claimer, saved responses, /tag categorization, and automation (auto-close, reminders, auto-unclaim).
Jump to: Features · Quick start · Configuration · Staff categories · Commands · Project layout
Table of contents
- Features
- Architecture
- Prerequisites
- Quick start
- Installation
- Configuration
- Staff personal categories & mirror channels
- Running the bot
- Discord commands
- Ticket UI (buttons & modals)
- Tag & response system
- Panel system
- Channel renames & moves (rate limits)
- Project structure
- Database collections
- HTTP: healthcheck & bOSScord API
- Gmail OAuth refresh token
- Documentation in
docs/ - Troubleshooting
- References
- License
Features
Email → Discord
- 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).
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 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 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.
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
/apifor the bOSScord cockpit whenBOSSCORD_API_KEYis set.
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 handlers/commands.js│
│ services/staffChannel.js │
│ │ │
│ ▼ │
│ MongoDB (Mongoose) ◄── models.js │
│ │
│ Express: GET / → "Active" ; optional /api → routes/bosscord.js │
└─────────────────────────────────────────────────────────────────┘
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+ 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. |
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, runnpm run start:test(setsENV_FILE). - 1Password CLI:
npm run start:1p/start:test:1pinject secrets (seedocs/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. Discord env names in tables below match .env.example.
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 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. |
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. |
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 (or with ENV_FILE=.env.test).
Server & optional 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. |
BOSSCORD_API_KEY |
— | If set, mounts /api (bOSScord); use a strong random key. |
BOSSCORD_CORS_ORIGIN |
* |
Optional CORS for the API. |
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_*. - Priority:
PRIORITY_ENABLED,DEFAULT_PRIORITY,PRIORITY_*_EMOJI.
Game list
GAME_LIST=comma,separated,names — used for detection/normalization in email handling.
Staff personal categories & mirror channels
When configured:
- Claim: DB stores
claimerId(Discord user id) and optionalstaffChannelId. Main channel may move toSTAFF_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). - 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 (StaffSettingsin MongoDB). - Unclaim: mirror channel is deleted;
claimerId/staffChannelIdcleared; channel may rename with 🟢 and move toSTAFF_T1_CATEGORYif set. - 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_CATEGORYwhen configured; mirror channels can move with the tier category. - Close: mirror channel is deleted when the ticket closes.
Running the bot (test and Docker)
npm start
# or
node broccolini-discord.js
Test:
npm run start:test
npm run test-mongodb:test
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.
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/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 / 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 (+ 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/
│ ├── 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/
│ ├── 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
└── .env.example / .env.test.example
Database collections
| 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. |
HTTP: healthcheck & bOSScord API
GET /→ plain textActive(intended for load balancers / DockerHEALTHCHECK)./api/*is registered only afterreadywhenBOSSCORD_API_KEYis set. JSON body parsing enabled. Seeroutes/bosscord.jsfor routes.
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) |
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. |
References
| Technology | Link |
|---|---|
| discord.js v14 | discord.js guide |
| Google APIs (Gmail) | googleapis Node |
| Mongoose | mongoosejs.com |
| Express | expressjs.com |
License
ISC