Sync broccolini-bot: rename from zammad, docs in docs/, security gitignore, remove zammad deps
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
38
.env.example
38
.env.example
@@ -1,5 +1,5 @@
|
||||
# =============================================================================
|
||||
# GMAIL–DISCORD–ZAMMAD BRIDGE – Example environment (no secrets)
|
||||
# Broccolini Bot – Example environment (no secrets)
|
||||
# Copy to .env and fill in real values. See README for full docs.
|
||||
# =============================================================================
|
||||
|
||||
@@ -21,14 +21,14 @@ EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optio
|
||||
|
||||
# Escalation categories (tier 2 and tier 3)
|
||||
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
|
||||
EMAIL_ESCALATED_CATEGORY_ID= # Fallback escalation category (email)
|
||||
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/channel (email)
|
||||
EMAIL_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category/channel (email)
|
||||
|
||||
# Logging, transcripts, and utility
|
||||
ROLE_ID_TO_PING= # Role ID to ping on new tickets
|
||||
ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also accepts ROLE_TO_PING_ID as alias)
|
||||
TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
|
||||
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
|
||||
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
|
||||
@@ -37,7 +37,8 @@ ACCOUNT_INFO_CHANNEL_ID= # Channel for account info lookups; optional
|
||||
DISCORD_CHANNEL_ID= # General Discord channel (if used)
|
||||
|
||||
# --- Discord: Ticket copy & buttons ---
|
||||
ESCALATION_MESSAGE= # Message shown when a ticket is escalated
|
||||
# ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME
|
||||
ESCALATION_MESSAGE= # e.g. Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.
|
||||
BUTTON_LABEL_CLOSE=Close Ticket
|
||||
BUTTON_LABEL_CLAIM=Claim
|
||||
BUTTON_LABEL_UNCLAIM=Unclaim
|
||||
@@ -51,29 +52,26 @@ GOOGLE_CLIENT_SECRET= # OAuth2 Client Secret
|
||||
REFRESH_TOKEN= # OAuth2 refresh token for the support inbox
|
||||
MY_EMAIL= # Support inbox email address
|
||||
|
||||
# --- Zammad ---
|
||||
ZAMMAD_TOKEN= # Zammad API token
|
||||
ZAMMAD_URL= # Base URL of Zammad (e.g. https://zammad.example.com or ${NGROK_URL})
|
||||
ZAMMAD_CLIENT_ID= # Zammad OAuth client ID (if used)
|
||||
ZAMMAD_CLIENT_SECRET= # Zammad OAuth client secret (if used)
|
||||
ZAMMAD_EMAIL_GROUP=Email Users # Zammad group for email-sourced tickets
|
||||
ZAMMAD_DISCORD_GROUP=Discord Users # Zammad group for Discord-sourced tickets
|
||||
|
||||
# --- Server & URLs ---
|
||||
NGROK_URL= # ngrok or public URL (optional; used if ZAMMAD_URL=${NGROK_URL})
|
||||
ZAMMAD_PORT=3050 # Port for Zammad-related server (if any)
|
||||
NGROK_URL= # Public URL (optional); run ngrok outside this repo
|
||||
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
|
||||
|
||||
# --- Database ---
|
||||
MONGODB_URI= # MongoDB connection string (e.g. mongodb+srv://user:pass@cluster/dbname)
|
||||
# MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently)
|
||||
|
||||
# --- Branding & copy ---
|
||||
SUPPORT_NAME=Support
|
||||
LOGO_URL= # URL of logo shown in embeds (optional)
|
||||
EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks)
|
||||
TICKET_CLOSE_SUBJECT_PREFIX=[Resolved]
|
||||
TICKET_CLOSE_MESSAGE= # Body of closure email
|
||||
# Email tickets only (closure email body):
|
||||
TICKET_CLOSE_MESSAGE= # Body of closure email to customer
|
||||
TICKET_CLOSE_SIGNATURE= # Signature on closure email
|
||||
# Discord ticket closure (in-channel before transcript, transcript post, and auto-close):
|
||||
DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.)
|
||||
DISCORD_TRANSCRIPT_MESSAGE= # When posting transcript / DM to user. Use {channel_name}, {email}, {date_opened}, {date_closed}
|
||||
DISCORD_AUTO_CLOSE_MESSAGE= # Message in ticket when auto-closed (e.g. ... If you still need assistance, please open a new ticket.)
|
||||
|
||||
# --- Ticket limits & permissions ---
|
||||
GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally
|
||||
@@ -91,10 +89,12 @@ AUTO_CLOSE_MESSAGE= # Message when ticket is auto-closed
|
||||
# --- Reminders ---
|
||||
REMINDER_ENABLED=false
|
||||
REMINDER_AFTER_HOURS=24
|
||||
REMINDER_MESSAGE= # Supports {hours}
|
||||
TICKET_WELCOME_MESSAGE= # Message when ticket channel is created
|
||||
TICKET_CLAIMED_MESSAGE= # Supports {staff_name}
|
||||
TICKET_UNCLAIMED_MESSAGE=
|
||||
# REMINDER_MESSAGE: use {ping} (claimer mention or role ping), {hours}
|
||||
REMINDER_MESSAGE= # e.g. Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.
|
||||
TICKET_WELCOME_MESSAGE= # Message in welcome embed when ticket channel is created
|
||||
# TICKET_CLAIMED_MESSAGE / TICKET_UNCLAIMED_MESSAGE: use {staff_mention}, {staff_name}
|
||||
TICKET_CLAIMED_MESSAGE= # e.g. Ticket claimed by {staff_mention} 🚀
|
||||
TICKET_UNCLAIMED_MESSAGE= # e.g. Ticket unclaimed by {staff_mention} ☀️
|
||||
|
||||
# --- Priority (low, normal, medium, high; default: normal) ---
|
||||
PRIORITY_ENABLED=false
|
||||
|
||||
114
.env.test.example
Normal file
114
.env.test.example
Normal file
@@ -0,0 +1,114 @@
|
||||
# =============================================================================
|
||||
# Broccolini Bot – Test environment template (no secrets)
|
||||
# Copy to .env.test and fill with TEST-only values. Run with ENV_FILE=.env.test
|
||||
# so changes are tried here first, then migrated to .env after confirmation.
|
||||
# See ENV_AND_SECURITY.md. Never commit .env or .env.test.
|
||||
# =============================================================================
|
||||
|
||||
# --- Discord: Core (use a test guild / bot if possible) ---
|
||||
DISCORD_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
|
||||
# --- Discord: Channel & category IDs (test server) ---
|
||||
DISCORD_TICKET_CATEGORY_ID=
|
||||
TICKET_CATEGORY_ID=
|
||||
DISCORD_THREAD_CHANNEL_ID=
|
||||
EMAIL_THREAD_CHANNEL_ID=
|
||||
|
||||
# --- Escalation (optional for test) ---
|
||||
DISCORD_ESCALATED_CATEGORY_ID=
|
||||
EMAIL_ESCALATED_CATEGORY_ID=
|
||||
DISCORD_ESCALATED2_CHANNEL_ID=
|
||||
DISCORD_ESCALATED3_CHANNEL_ID=
|
||||
EMAIL_ESCALATED2_CHANNEL_ID=
|
||||
EMAIL_ESCALATED3_CHANNEL_ID=
|
||||
|
||||
# --- Logging & utility ---
|
||||
ROLE_ID_TO_PING=
|
||||
TRANSCRIPT_CHANNEL_ID=
|
||||
LOGGING_CHANNEL_ID=
|
||||
DEBUGGING_CHANNEL_ID=
|
||||
BACKUP_EXPORT_CHANNEL_ID=
|
||||
ACCOUNT_INFO_CHANNEL_ID=
|
||||
DISCORD_CHANNEL_ID=
|
||||
|
||||
# --- Buttons / copy ---
|
||||
ESCALATION_MESSAGE=
|
||||
BUTTON_LABEL_CLOSE=Close Ticket
|
||||
BUTTON_LABEL_CLAIM=Claim
|
||||
BUTTON_LABEL_UNCLAIM=Unclaim
|
||||
BUTTON_EMOJI_CLOSE=🔒
|
||||
BUTTON_EMOJI_CLAIM=📌
|
||||
BUTTON_EMOJI_UNCLAIM=🔓
|
||||
|
||||
# --- Google / Gmail (test inbox or same as prod – your choice) ---
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
REFRESH_TOKEN=
|
||||
MY_EMAIL=
|
||||
|
||||
# --- Server ---
|
||||
NGROK_URL=
|
||||
DISCORD_ONLY_PORT=5001
|
||||
|
||||
# --- Database (use a separate test DB or db name to avoid data loss) ---
|
||||
MONGODB_URI=
|
||||
|
||||
# --- Branding & copy ---
|
||||
SUPPORT_NAME=Support (Test)
|
||||
LOGO_URL=
|
||||
EMAIL_SIGNATURE=
|
||||
TICKET_CLOSE_SUBJECT_PREFIX=[Resolved]
|
||||
TICKET_CLOSE_MESSAGE=
|
||||
TICKET_CLOSE_SIGNATURE=
|
||||
DISCORD_CLOSE_MESSAGE=
|
||||
DISCORD_TRANSCRIPT_MESSAGE=
|
||||
DISCORD_AUTO_CLOSE_MESSAGE=
|
||||
|
||||
# --- Limits & permissions ---
|
||||
GLOBAL_TICKET_LIMIT=5
|
||||
TICKET_LIMIT_PER_CATEGORY=3
|
||||
RATE_LIMIT_TICKETS_PER_USER=0
|
||||
RATE_LIMIT_WINDOW_MINUTES=60
|
||||
BLACKLISTED_ROLES=
|
||||
ADDITIONAL_STAFF_ROLES=
|
||||
|
||||
# --- Auto-close / reminders ---
|
||||
AUTO_CLOSE_ENABLED=false
|
||||
AUTO_CLOSE_AFTER_HOURS=72
|
||||
AUTO_CLOSE_MESSAGE=
|
||||
REMINDER_ENABLED=false
|
||||
REMINDER_AFTER_HOURS=24
|
||||
REMINDER_MESSAGE=
|
||||
TICKET_WELCOME_MESSAGE=
|
||||
TICKET_CLAIMED_MESSAGE=
|
||||
TICKET_UNCLAIMED_MESSAGE=
|
||||
|
||||
# --- Priority ---
|
||||
PRIORITY_ENABLED=false
|
||||
DEFAULT_PRIORITY=normal
|
||||
PRIORITY_HIGH_EMOJI=🔴
|
||||
PRIORITY_MEDIUM_EMOJI=🟡
|
||||
PRIORITY_LOW_EMOJI=🟢
|
||||
|
||||
# --- Claiming ---
|
||||
CLAIM_TIMEOUT_ENABLED=false
|
||||
CLAIM_TIMEOUT_HOURS=48
|
||||
AUTO_UNCLAIM_ENABLED=false
|
||||
AUTO_UNCLAIM_AFTER_HOURS=24
|
||||
ALLOW_CLAIM_OVERWRITE=false
|
||||
|
||||
# --- Thread (legacy) ---
|
||||
USE_THREADS=false
|
||||
THREAD_PARENT_CHANNEL=
|
||||
|
||||
# --- Game list ---
|
||||
GAME_LIST=Project Zomboid, Minecraft, ...
|
||||
|
||||
# --- Embed colors ---
|
||||
EMBED_COLOR_OPEN=0x00FF00
|
||||
EMBED_COLOR_CLOSED=0xFF0000
|
||||
EMBED_COLOR_CLAIMED=0xFFFF00
|
||||
EMBED_COLOR_ESCALATED=0xFF6600
|
||||
EMBED_COLOR_INFO=0x1e2124
|
||||
33
.gitignore
vendored
33
.gitignore
vendored
@@ -1,13 +1,21 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment / Secrets (keep .env.example committed for new deploys)
|
||||
# Documentation: docs/ is committed (all .md except README.md live in docs/)
|
||||
|
||||
# Environment / Secrets (keep .env.example and .env.test.example committed; never commit .env or .env.test)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
# Explicit so test env is never committed; agents must not modify .env without user confirmation
|
||||
.env.test
|
||||
# Local backup of .env (do not commit)
|
||||
.env.backup
|
||||
|
||||
# SQLite databases (legacy, migrated to MongoDB)
|
||||
*.sqlite
|
||||
# GitLab / SSH (do not commit keys)
|
||||
gitlab
|
||||
gitlab.pub
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
@@ -23,5 +31,20 @@ npm-debug.log*
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Misc
|
||||
backup-sqlite/
|
||||
toolbox
|
||||
.cursor/
|
||||
|
||||
# Custom bot project docs (internal reference; do not commit)
|
||||
custombotproject/
|
||||
|
||||
# Private keys and credential files (do not commit)
|
||||
*.pem
|
||||
credentials.json
|
||||
token.json
|
||||
token.pickle
|
||||
|
||||
# ngrok (run from ~/ngrok; do not commit config or tokens)
|
||||
ngrok.yml
|
||||
cursor.yml
|
||||
*.local.yml
|
||||
|
||||
|
||||
174
README.md
174
README.md
@@ -1,8 +1,10 @@
|
||||
# Gmail Bridge
|
||||
# Broccolini Bot
|
||||
|
||||
A Node.js support-ticket bridge that connects **Gmail**, **Discord**, **Zammad**, and **MongoDB** into a unified helpdesk system. Incoming support emails are automatically turned into Discord ticket channels, staff replies in Discord are relayed back to the sender via Gmail, and every interaction is synced to Zammad and persisted in MongoDB.
|
||||
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.
|
||||
|
||||
Built for game-server hosting support (Indifferent Broccoli), with built-in game detection, configurable automation, and a rich set of Discord slash commands.
|
||||
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.
|
||||
|
||||
**Quick links:** [Installation](#installation) · [Configuration](#configuration) · [Discord Commands](#discord-commands) · [Documentation](#documentation)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +17,6 @@ Built for game-server hosting support (Indifferent Broccoli), with built-in game
|
||||
- [Configuration](#configuration)
|
||||
- [Discord](#discord)
|
||||
- [Google OAuth2 / Gmail](#google-oauth2--gmail)
|
||||
- [Zammad](#zammad)
|
||||
- [MongoDB](#mongodb)
|
||||
- [Branding & Messages](#branding--messages)
|
||||
- [Automation](#automation)
|
||||
@@ -24,6 +25,7 @@ Built for game-server hosting support (Indifferent Broccoli), with built-in game
|
||||
- [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)
|
||||
@@ -31,7 +33,9 @@ Built for game-server hosting support (Indifferent Broccoli), with built-in game
|
||||
- [Database Schema](#database-schema)
|
||||
- [API Integrations](#api-integrations)
|
||||
- [Healthcheck](#healthcheck)
|
||||
- [Documentation](#documentation)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [References](#references)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
@@ -47,12 +51,6 @@ Built for game-server hosting support (Indifferent Broccoli), with built-in game
|
||||
### 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
|
||||
- Each reply is also recorded as an article in Zammad
|
||||
|
||||
### Zammad Integration
|
||||
- Automatically creates Zammad tickets with game info, priority, and group assignment
|
||||
- Syncs user data (email, Discord ID) between MongoDB and Zammad
|
||||
- Closes Zammad tickets when Discord tickets are resolved
|
||||
|
||||
### Ticket Management
|
||||
- **Claim / Unclaim** -- Staff can claim tickets; optional auto-unclaim after inactivity
|
||||
@@ -88,20 +86,20 @@ Built for game-server hosting support (Indifferent Broccoli), with built-in game
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ GMAIL BRIDGE │
|
||||
│ BROCCOLINI BOT │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────┐ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Gmail │─────>│ gmail-poll.js │─────>│ Discord │ │
|
||||
│ │ (inbox) │ │ (every 30s) │ │ (ticket channel)│ │
|
||||
│ │ Gmail │─────>│ gmail-poll.js │─────>│ Discord │ │
|
||||
│ │ (inbox) │ │ (every 30s) │ │ (ticket channel)│ │
|
||||
│ └───────────┘ └───────┬────────┘ └───────▲──────────┘ │
|
||||
│ │ │ │
|
||||
│ v │ │
|
||||
│ ┌───────────┐ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Zammad │<────>│ services/ │<────>│ handlers/ │ │
|
||||
│ │ (tickets) │ │ gmail.js │ │ messages.js │ │
|
||||
│ └───────────┘ │ zammad.js │ │ buttons.js │ │
|
||||
│ │ tickets.js │ │ commands.js │ │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ │
|
||||
│ │ services/ │ │ handlers/ │ │
|
||||
│ │ gmail.js │<────>│ messages.js │ │
|
||||
│ │ tickets.js │ │ buttons.js │ │
|
||||
│ │ guildSettings │ │ commands.js │ │
|
||||
│ └───────┬────────┘ └──────────────────┘ │
|
||||
│ │ │
|
||||
│ v │
|
||||
@@ -113,53 +111,53 @@ Built for game-server hosting support (Indifferent Broccoli), with built-in game
|
||||
│ Events: │
|
||||
│ ready → Connect DB, register commands, start jobs │
|
||||
│ interactionCreate → Buttons, slash commands, modals, menus │
|
||||
│ messageCreate → Discord replies → Gmail + Zammad │
|
||||
│ messageCreate → Discord replies → Gmail │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Ticket lifecycle:**
|
||||
|
||||
1. **Inbound email** -- Gmail poll detects a new unread message, creates a Discord channel, a Zammad ticket, and a MongoDB record.
|
||||
2. **Staff reply** -- A message in the Discord ticket channel is forwarded to the sender via Gmail and added as an article in Zammad.
|
||||
3. **Close** -- A transcript is generated, a closure email is sent, the Zammad ticket is closed, and the Discord channel is deleted.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Version |
|
||||
|---|---|
|
||||
| Node.js | >= 18.x |
|
||||
| npm | >= 9.x |
|
||||
| MongoDB | >= 5.x (Atlas or self-hosted) |
|
||||
| Zammad | >= 6.x |
|
||||
|-------------|---------|
|
||||
| Node.js | ≥ 18.x |
|
||||
| npm | ≥ 9.x |
|
||||
| MongoDB | ≥ 5.x (Atlas or self-hosted) |
|
||||
|
||||
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)
|
||||
- A **Zammad instance** accessible via URL with an API token
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <your-repo-url>
|
||||
cd gmail-bridge
|
||||
Single-level repo: all commands run from the repo root. Create `.env` in the repo root (copy from `.env.example`).
|
||||
|
||||
# Install dependencies
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd broccolini-bot
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Edit .env with your Discord, Gmail, and MongoDB credentials (see Configuration).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `.env` file in the project root. All configuration is loaded via environment variables.
|
||||
Create a `.env` file in the repo root (same directory as this README). All configuration is loaded via environment variables.
|
||||
|
||||
> **Important:** After changing `.env`, you must **restart the process** (`npm start` / `node zammad-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.
|
||||
> **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/ENV_AND_SECURITY.md).
|
||||
|
||||
### Discord
|
||||
|
||||
@@ -177,9 +175,10 @@ Create a `.env` file in the project root. All configuration is loaded via enviro
|
||||
| `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 |
|
||||
| `EMAIL_ESCALATED_CATEGORY_ID` | No | Category ID for escalated email tickets |
|
||||
| `ESCALATION_MESSAGE` | No | Message sent when a ticket is escalated |
|
||||
| `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}`) |
|
||||
|
||||
### Google OAuth2 / Gmail
|
||||
|
||||
@@ -190,17 +189,6 @@ Create a `.env` file in the project root. All configuration is loaded via enviro
|
||||
| `REFRESH_TOKEN` | Yes | OAuth2 Refresh Token for the support inbox |
|
||||
| `MY_EMAIL` | Yes | The support email address (e.g. `support@example.com`) |
|
||||
|
||||
### Zammad
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `ZAMMAD_URL` | Yes | Base URL of your Zammad instance |
|
||||
| `ZAMMAD_TOKEN` | Yes | Zammad API token |
|
||||
| `ZAMMAD_EMAIL_GROUP` | No | Zammad group for email-sourced tickets (default: `Email Users`) |
|
||||
| `ZAMMAD_DISCORD_GROUP` | No | Zammad group for Discord-sourced tickets (default: `Discord Users`) |
|
||||
|
||||
> **Tip:** If your Zammad instance is behind ngrok, set `NGROK_URL` and use `ZAMMAD_URL=${NGROK_URL}` -- dotenv-expand will resolve it.
|
||||
|
||||
### MongoDB
|
||||
|
||||
| Variable | Required | Description |
|
||||
@@ -304,7 +292,7 @@ GAME_LIST=Project Zomboid, Satisfactory, Palworld, Minecraft, Valheim, ...
|
||||
npm start
|
||||
|
||||
# Or directly
|
||||
node zammad-discord.js
|
||||
node broccolini-discord.js
|
||||
```
|
||||
|
||||
On startup the bot will:
|
||||
@@ -316,17 +304,19 @@ On startup the bot will:
|
||||
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, run `npm run register` (or restart) to re-register.
|
||||
**Note:** Changing `.env` requires restarting the bot. Slash commands are registered on startup; if commands don’t update, restart the bot to re-register.
|
||||
|
||||
### Optional: Create Zammad Groups
|
||||
### Test Environment
|
||||
|
||||
If your Zammad instance doesn't already have the required groups:
|
||||
To try config changes without affecting production, use a **test env**. Copy `.env.test.example` to `.env.test`, fill it with test-only values (e.g. test guild, test MongoDB database), and run:
|
||||
|
||||
```bash
|
||||
npm run create-zammad-objects
|
||||
npm run start:test
|
||||
```
|
||||
|
||||
This creates the "Email Users" and "Discord Users" groups and lists available priorities and states.
|
||||
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/ENV_AND_SECURITY.md)** for the full workflow, security checklist, and agent rules.
|
||||
|
||||
To test the MongoDB connection from the repo root: `npm run test-mongodb`.
|
||||
|
||||
---
|
||||
|
||||
@@ -430,8 +420,8 @@ The panel system allows users to create tickets directly from Discord without se
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
gmail-bridge/
|
||||
├── zammad-discord.js # Entry point - initializes bot, events, and jobs
|
||||
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.)
|
||||
@@ -447,17 +437,20 @@ gmail-bridge/
|
||||
│ ├── 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
|
||||
│ ├── messages.js # Discord → Gmail reply forwarding
|
||||
│ └── setup.js # Guild setup / configuration flow
|
||||
│
|
||||
├── services/
|
||||
│ ├── gmail.js # Gmail OAuth2, send replies, closure emails
|
||||
│ ├── tickets.js # Ticket CRUD, auto-close, reminders, auto-unclaim
|
||||
│ └── zammad.js # Zammad API client (tickets, users, articles)
|
||||
│ ├── 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/
|
||||
│ └── create-zammad-objects.js # Utility to create Zammad groups
|
||||
│ ├── backup-env.js # Copy .env to .env.backup
|
||||
│ └── test-mongodb.js # MongoDB connection test
|
||||
│
|
||||
├── docs/ # Additional documentation
|
||||
├── docs/ # Additional documentation (QUICKSTART, MONGODB_SETUP, ENV_AND_SECURITY, etc.)
|
||||
├── .env # Environment variables (not committed)
|
||||
├── package.json
|
||||
└── package-lock.json
|
||||
@@ -471,7 +464,7 @@ The bot uses MongoDB via Mongoose. Key collections:
|
||||
|
||||
| Collection | Purpose |
|
||||
|---|---|
|
||||
| `Ticket` | Core ticket data: Gmail thread ID, Discord channel ID, Zammad ticket ID, sender info, status, priority, claimed-by, timestamps |
|
||||
| `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) |
|
||||
@@ -498,15 +491,6 @@ The bot uses MongoDB via Mongoose. Key collections:
|
||||
- **Interactions:** Slash commands, buttons, modals, context menus, autocomplete
|
||||
- **Channels:** Create/delete ticket channels, manage permissions per user
|
||||
|
||||
### Zammad API
|
||||
|
||||
- **Authentication:** Token-based via `Authorization: Token token=...`
|
||||
- **Tickets:** Create (`POST /api/v1/tickets`), update/close (`PATCH /api/v1/tickets/:id`)
|
||||
- **Articles:** Add notes and replies (`POST /api/v1/ticket_articles`)
|
||||
- **Users:** Search (`GET /api/v1/users/search`), create (`POST /api/v1/users`), update (`PATCH /api/v1/users/:id`)
|
||||
|
||||
---
|
||||
|
||||
## Healthcheck
|
||||
|
||||
An Express server runs on the port defined by `DISCORD_ONLY_PORT` (default: `5000`).
|
||||
@@ -515,7 +499,24 @@ An Express server runs on the port defined by `DISCORD_ONLY_PORT` (default: `500
|
||||
GET / → "Active"
|
||||
```
|
||||
|
||||
Use this endpoint for uptime monitoring or container health probes.
|
||||
Use this endpoint for uptime monitoring or container health probes. Optional: set `HEALTHCHECK_HOST=127.0.0.1` in `.env` to bind the healthcheck server to localhost only; omit to listen on all interfaces.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Additional guides and reference docs live in **`docs/`**:
|
||||
|
||||
| Doc | Description |
|
||||
|-----|-------------|
|
||||
| [QUICKSTART](docs/QUICKSTART.md) | Get started in a few minutes: first response, panel, tags, priority |
|
||||
| [ENV_AND_SECURITY](docs/ENV_AND_SECURITY.md) | Test env workflow, security checklist, agent rules |
|
||||
| [MONGODB_SETUP](docs/MONGODB_SETUP.md) | MongoDB connection, schemas, and testing |
|
||||
| [PROJECT_STRUCTURE](docs/PROJECT_STRUCTURE.md) | File and directory layout |
|
||||
| [PROPOSAL](docs/PROPOSAL.md) | Roadmap and possible next steps |
|
||||
| [PHASE_FEATURES](docs/PHASE_FEATURES.md) | Phased feature list and variables |
|
||||
| [FEATURES_SUMMARY](docs/FEATURES_SUMMARY.md) · [NEW_FEATURES](docs/NEW_FEATURES.md) | Feature overview and changelog |
|
||||
| [DISCORD_API_IMPROVEMENTS](docs/DISCORD_API_IMPROVEMENTS.md) · [DISCORD_API_VALIDATION](docs/DISCORD_API_VALIDATION.md) | Discord API implementation notes |
|
||||
|
||||
---
|
||||
|
||||
@@ -536,15 +537,10 @@ Use this endpoint for uptime monitoring or container health probes.
|
||||
### 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.
|
||||
|
||||
### Zammad API errors
|
||||
|
||||
- Confirm `ZAMMAD_URL` is reachable (if using ngrok, ensure the tunnel is active).
|
||||
- Verify the `ZAMMAD_TOKEN` has sufficient permissions.
|
||||
- Run `npm run create-zammad-objects` to ensure required groups exist.
|
||||
|
||||
### Tickets not creating
|
||||
|
||||
- Check that `TICKET_CATEGORY_ID` points to a valid Discord category.
|
||||
@@ -559,6 +555,22 @@ Use this endpoint for uptime monitoring or container health probes.
|
||||
|
||||
---
|
||||
|
||||
## 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) |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
ISC
|
||||
|
||||
@@ -19,9 +19,7 @@ const { sendTicketClosedEmail } = require('./services/gmail');
|
||||
const { checkAutoClose, checkReminders, checkAutoUnclaim } = require('./services/tickets');
|
||||
const { registerCommands } = require('./commands/register');
|
||||
const { poll } = require('./gmail-poll');
|
||||
const { syncZammadReplies } = require('./services/zammad-sync');
|
||||
const { setClient: setDebugClient } = require('./services/debugLog');
|
||||
const { ZAMMAD } = require('./config');
|
||||
|
||||
// Re-export utilities for any external consumers
|
||||
const { sendGmailReply } = require('./services/gmail');
|
||||
@@ -57,13 +55,11 @@ const client = new Client({
|
||||
|
||||
// --- EVENT: interactionCreate ---
|
||||
client.on('interactionCreate', async interaction => {
|
||||
// Account-info "send to channel" button
|
||||
if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) {
|
||||
const handled = await handleSendAccountInfoToChannel(interaction);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Setup wizard buttons (must run before generic button handler so we don't hit "Data missing.")
|
||||
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
|
||||
try {
|
||||
const handled = await handleSetupButton(interaction);
|
||||
@@ -78,56 +74,47 @@ client.on('interactionCreate', async interaction => {
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons (includes open_ticket, claim, close, priority, tag-delete, etc.)
|
||||
if (interaction.isButton()) {
|
||||
return handleButton(interaction);
|
||||
}
|
||||
|
||||
// Setup wizard modal (panel name)
|
||||
if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) {
|
||||
const handled = await handleSetupModal(interaction);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Modal submissions (ticket_modal from the panel button)
|
||||
if (interaction.isModalSubmit() && ['ticket_modal', 'ticket_modal_thread', 'ticket_modal_channel'].includes(interaction.customId)) {
|
||||
return handleTicketModal(interaction);
|
||||
}
|
||||
|
||||
// Setup wizard select menus (roles, category, transcript channel, panel channel)
|
||||
if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) {
|
||||
const handled = await handleSetupSelect(interaction);
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
// Slash commands
|
||||
if (interaction.isChatInputCommand()) {
|
||||
return handleCommand(interaction);
|
||||
}
|
||||
|
||||
// Context menu commands
|
||||
if (interaction.isMessageContextMenuCommand() || interaction.isUserContextMenuCommand()) {
|
||||
return handleContextMenu(interaction);
|
||||
}
|
||||
|
||||
// Autocomplete
|
||||
if (interaction.isAutocomplete()) {
|
||||
return handleAutocomplete(interaction);
|
||||
}
|
||||
});
|
||||
|
||||
// --- EVENT: messageCreate (Discord → Gmail reply) ---
|
||||
client.on('messageCreate', handleDiscordReply);
|
||||
|
||||
// --- EVENT: ready ---
|
||||
client.once('ready', async () => {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
console.error('MONGODB_URI is not set in .env. Bridge requires MongoDB.');
|
||||
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
|
||||
process.exit(1);
|
||||
}
|
||||
await connectMongoDB(process.env.MONGODB_URI);
|
||||
setDebugClient(client);
|
||||
console.log(`gmail-discord instance active on port ${CONFIG.PORT}`);
|
||||
console.log(`Broccolini Bot active on port ${CONFIG.PORT}`);
|
||||
|
||||
const guild = CONFIG.DISCORD_GUILD_ID
|
||||
? client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID)
|
||||
@@ -146,32 +133,21 @@ client.once('ready', async () => {
|
||||
|
||||
registerCommands().catch(console.error);
|
||||
|
||||
// Gmail polling every 30 seconds
|
||||
setInterval(() => poll(client), 30000);
|
||||
poll(client);
|
||||
|
||||
// Zammad reply sync: push agent replies from Zammad to Discord/Gmail every 30 seconds
|
||||
if (ZAMMAD?.URL && ZAMMAD?.TOKEN) {
|
||||
setInterval(() => syncZammadReplies(client), 30000);
|
||||
syncZammadReplies(client);
|
||||
console.log('✓ Zammad reply sync enabled: every 30 seconds');
|
||||
}
|
||||
|
||||
// Auto-close check every hour
|
||||
if (CONFIG.AUTO_CLOSE_ENABLED) {
|
||||
setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000);
|
||||
checkAutoClose(client, sendTicketClosedEmail);
|
||||
console.log('✓ Auto-close enabled: checking every hour');
|
||||
}
|
||||
|
||||
// Reminder check every 30 minutes
|
||||
if (CONFIG.REMINDER_ENABLED) {
|
||||
setInterval(() => checkReminders(client), 30 * 60 * 1000);
|
||||
checkReminders(client);
|
||||
console.log('✓ Reminders enabled: checking every 30 minutes');
|
||||
}
|
||||
|
||||
// Auto-unclaim check every hour
|
||||
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
|
||||
setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000);
|
||||
checkAutoUnclaim(client);
|
||||
@@ -183,11 +159,11 @@ client.once('ready', async () => {
|
||||
|
||||
client.login(CONFIG.DISCORD_TOKEN);
|
||||
|
||||
// --- HEALTHCHECK ---
|
||||
const app = express();
|
||||
app.get('/', (req, res) => res.send('Active'));
|
||||
app.listen(CONFIG.PORT, () => {
|
||||
console.log(`Healthcheck server listening on ${CONFIG.PORT}`);
|
||||
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
|
||||
app.listen(CONFIG.PORT, healthcheckHost, () => {
|
||||
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
@@ -116,6 +116,7 @@ async function registerCommands() {
|
||||
.setDescription('Set the topic/description for this ticket')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('text')
|
||||
@@ -317,6 +318,7 @@ async function registerCommands() {
|
||||
.setDescription('Set the priority of this ticket')
|
||||
.setContexts([InteractionContextType.Guild])
|
||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
||||
.addStringOption(opt =>
|
||||
opt
|
||||
.setName('level')
|
||||
|
||||
40
config.js
40
config.js
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* Bridge configuration and game lists.
|
||||
* Broccolini Bot configuration and game lists.
|
||||
* Load dotenv so env is available when this module is required first.
|
||||
* dotenv-expand resolves ${NGROK_URL} etc. in .env.
|
||||
*
|
||||
* Test env: set ENV_FILE=.env.test to load .env.test instead of .env (see ENV_AND_SECURITY.md).
|
||||
* Never commit .env or .env.test; agents must not modify .env without explicit user confirmation.
|
||||
*/
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const dotenvExpand = require('dotenv-expand');
|
||||
dotenvExpand.expand(dotenv.config({ debug: true }));
|
||||
|
||||
const ZAMMAD = {
|
||||
URL: process.env.ZAMMAD_URL,
|
||||
TOKEN: process.env.ZAMMAD_TOKEN,
|
||||
EMAIL_GROUP: process.env.ZAMMAD_EMAIL_GROUP || 'Email Users',
|
||||
DISCORD_GROUP: process.env.ZAMMAD_DISCORD_GROUP || 'Discord Users'
|
||||
};
|
||||
const envPath = process.env.ENV_FILE
|
||||
? path.resolve(process.cwd(), process.env.ENV_FILE)
|
||||
: undefined;
|
||||
const parsed = dotenv.config({ path: envPath, debug: process.env.NODE_ENV === 'development' });
|
||||
if (envPath && parsed.error) {
|
||||
console.warn(`[config] ENV_FILE=${process.env.ENV_FILE} not found or unreadable:`, parsed.error.message);
|
||||
}
|
||||
dotenvExpand.expand(parsed);
|
||||
|
||||
const CONFIG = {
|
||||
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
|
||||
@@ -34,13 +39,14 @@ const CONFIG = {
|
||||
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
||||
BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null,
|
||||
ACCOUNT_INFO_CHANNEL_ID: process.env.ACCOUNT_INFO_CHANNEL_ID || null,
|
||||
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
|
||||
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
||||
CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
||||
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
|
||||
LOGO_URL: process.env.LOGO_URL,
|
||||
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
|
||||
PORT: process.env.DISCORD_ONLY_PORT || 5000,
|
||||
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '<br>'),
|
||||
GAME_LIST: process.env.GAME_LIST || '',
|
||||
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
|
||||
@@ -51,10 +57,15 @@ const CONFIG = {
|
||||
DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_ESCALATED2_CHANNEL_ID || null,
|
||||
EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null,
|
||||
DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null,
|
||||
ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'This email ticket has been escalated.',
|
||||
ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.',
|
||||
TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]',
|
||||
// Email tickets only (closure email body):
|
||||
TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || 'This ticket has been marked as resolved. If you would like to re-open this issue, please reply to this email.',
|
||||
TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.',
|
||||
// Discord ticket closure (in-channel and transcript):
|
||||
DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.',
|
||||
DISCORD_TRANSCRIPT_MESSAGE: process.env.DISCORD_TRANSCRIPT_MESSAGE || 'Your ticket **{channel_name}** has been closed. Here is your transcript. If you still need assistance, please open a new ticket.',
|
||||
DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.',
|
||||
AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true',
|
||||
AUTO_CLOSE_AFTER_HOURS: parseInt(process.env.AUTO_CLOSE_AFTER_HOURS) || 72,
|
||||
AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.',
|
||||
@@ -64,12 +75,12 @@ const CONFIG = {
|
||||
RATE_LIMIT_WINDOW_MINUTES: parseInt(process.env.RATE_LIMIT_WINDOW_MINUTES) || 60,
|
||||
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
|
||||
ADDITIONAL_STAFF_ROLES: (process.env.ADDITIONAL_STAFF_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
|
||||
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || 'Thank you for contacting support! A team member will assist you shortly.',
|
||||
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'This ticket has been claimed by {staff_name}.',
|
||||
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'This ticket is now available for any staff member.',
|
||||
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
|
||||
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
|
||||
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',
|
||||
REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true',
|
||||
REMINDER_AFTER_HOURS: parseInt(process.env.REMINDER_AFTER_HOURS) || 24,
|
||||
REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.',
|
||||
REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.',
|
||||
PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true',
|
||||
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
|
||||
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
|
||||
@@ -160,7 +171,6 @@ const GAME_NAME_TO_KEY = {
|
||||
|
||||
module.exports = {
|
||||
CONFIG,
|
||||
ZAMMAD,
|
||||
TICKET_TAGS,
|
||||
GAME_NAMES,
|
||||
GAME_ALIASES,
|
||||
|
||||
83
docs/COMMANDS_ANALYSIS.md
Normal file
83
docs/COMMANDS_ANALYSIS.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Broccolini Bot – Commands Analysis
|
||||
|
||||
Analysis of slash commands and context menus: who can see/use them today, and how to restrict usage to the support role (@broccolini / `ROLE_ID_TO_PING`) only so customers cannot use them.
|
||||
|
||||
---
|
||||
|
||||
## Current permission model
|
||||
|
||||
Commands use **Discord permission bits** via `setDefaultMemberPermissions(...)`. Only users who have that permission (or Administrator) see the command in the slash menu. There is **no role-ID check** at registration time (Discord API does not support “visible only to role X”).
|
||||
|
||||
| Command | Registration permission | Handler: staff only? | Who can use today |
|
||||
|--------|-------------------------|----------------------|--------------------|
|
||||
| **Slash commands** | | | |
|
||||
| `/escalate` | ManageMessages | Yes | Broccolini (staff role) only |
|
||||
| `/deescalate` | ManageMessages | Yes | Broccolini only |
|
||||
| `/add` | ManageMessages | Yes | Broccolini only |
|
||||
| `/remove` | ManageMessages | Yes | Broccolini only |
|
||||
| `/transfer` | ManageMessages | Yes (caller + target staff) | Broccolini only |
|
||||
| `/move` | ManageChannels | Yes | Broccolini only |
|
||||
| `/force-close` | ManageChannels | Yes | Broccolini only |
|
||||
| `/topic` | ManageMessages | Yes | Broccolini only |
|
||||
| `/tag` | ManageMessages | Yes | Broccolini only |
|
||||
| `/response` | ManageMessages | Yes | Broccolini only |
|
||||
| `/help` | **None** | **No** | **Everyone** |
|
||||
| `/setup` | ManageChannels | Yes | Broccolini only |
|
||||
| `/panel` | ManageChannels | Yes | Broccolini only |
|
||||
| `/email-routing` | ManageGuild | Yes | Broccolini only |
|
||||
| `/backup` | Administrator | Yes | Broccolini only |
|
||||
| `/export` | Administrator | Yes | Broccolini only |
|
||||
| `/priority` | ManageMessages | Yes | Broccolini only |
|
||||
| `/search` | ManageMessages | Yes | Broccolini only |
|
||||
| `/stats` | Administrator | Yes | Broccolini only |
|
||||
| `/accountinfo` | ManageMessages | Yes | Broccolini only |
|
||||
| **Context menus** | | | |
|
||||
| Create Ticket From Message | ManageMessages | Yes | Broccolini only |
|
||||
| View User Tickets | ManageMessages | Yes | Broccolini only |
|
||||
|
||||
---
|
||||
|
||||
## Role used for pinging
|
||||
|
||||
- **`ROLE_ID_TO_PING`** (env: `ROLE_ID_TO_PING`) – The “@broccolini” role ID used to ping support on new tickets, escalations, etc. Same as `ROLE_TO_PING_ID` in config (alias).
|
||||
- **`ADDITIONAL_STAFF_ROLES`** – Optional comma-separated role IDs; members with any of these roles can also use staff-only commands (same as having `ROLE_ID_TO_PING`). `/transfer` also validates that the *target* user has the main staff role.
|
||||
|
||||
---
|
||||
|
||||
## Goal: “Support @broccolini @role to ping id only – I don’t want customers using them”
|
||||
|
||||
You want **all** bot commands to be usable **only** by users who have the support role (the one you ping, i.e. `ROLE_ID_TO_PING`), so customers cannot use them.
|
||||
|
||||
- **Ping role** = same role as today: `ROLE_ID_TO_PING` (e.g. @broccolini). No change to who gets pinged.
|
||||
- **Who can run commands** = only members who have that role (and optionally `ADDITIONAL_STAFF_ROLES`). No permission-bit-only access.
|
||||
|
||||
Discord does **not** let you restrict slash commands by role ID in the registration. So the way to get “only this role can use” is:
|
||||
|
||||
1. **In the handler**: For every guild interaction, before running any command logic, check that the member has `ROLE_ID_TO_PING` (or one of `ADDITIONAL_STAFF_ROLES`). If not, reply ephemeral e.g. “This command is only available to the support team.” and do not run the command.
|
||||
2. **Registration**: Leave as-is (or tighten for consistency). The role check in the handler is the real gate; permission bits only control who *sees* the command. If you want only staff to see commands, you’d give the support role a permission (e.g. Manage Messages) and set that on commands; the handler check still ensures only that role (and optional additional staff roles) can actually run them.
|
||||
|
||||
---
|
||||
|
||||
## Who can use what (current behavior)
|
||||
|
||||
- **Only `/help`** – Usable by everyone (no staff role required). Visible in guild and in DMs; in DMs there is no role check.
|
||||
- **All other commands** (including `/topic` and `/priority`) – Broccolini-only. The handler requires the support role (`ROLE_ID_TO_PING` or `ADDITIONAL_STAFF_ROLES`) in guild; customers get an ephemeral “This command is only available to the support team.” and the command does not run.
|
||||
|
||||
Note: `/topic` and `/priority` use `ManageMessages` in registration, so they appear in the slash menu only for users with that permission (typically staff). The handler also enforces the staff role. Only `/help` has no default permission and is visible to everyone.
|
||||
|
||||
---
|
||||
|
||||
## Implementation (done)
|
||||
|
||||
- **`handlers/commands.js`**
|
||||
- **`hasStaffRole(member)`** – Returns true if the member has `ROLE_ID_TO_PING` or any `ADDITIONAL_STAFF_ROLES`.
|
||||
- **`requireStaffRole(interaction)`** – If the interaction is in a guild and the user is not staff, replies ephemeral with “This command is only available to the support team (@role).” and returns `true` (so the handler returns without running the command). If not in a guild (e.g. `/help` in DMs), or if no staff roles are configured, no block is applied.
|
||||
- **`handleCommand`** – Calls `requireStaffRole(interaction)` at the top **except for `/help`**; if it returns true, the handler returns immediately.
|
||||
- **`handleContextMenu`** – Same check at the top for “Create Ticket From Message” and “View User Tickets”.
|
||||
|
||||
- **Behavior**
|
||||
- **`/help`** – Can be used by everyone (no staff role required).
|
||||
- **All other slash commands** – In a guild, only users with the staff role (ROLE_ID_TO_PING or ADDITIONAL_STAFF_ROLES) can use them; others get an ephemeral message and the command does not run.
|
||||
- **Context menus** – Staff role required in guild.
|
||||
- In **DM** (e.g. `/help` in BotDM): No role check, so help and any other DM commands still work.
|
||||
- If **`ROLE_ID_TO_PING`** and **`ADDITIONAL_STAFF_ROLES`** are both unset, the check is skipped (backward compatible).
|
||||
@@ -60,9 +60,9 @@ All 12 improvements have been successfully implemented!
|
||||
- `/escalate` reason: 10-500 chars
|
||||
- `/transfer` reason: 10-500 chars
|
||||
- `/topic` text: 5-1024 chars
|
||||
- `/tag create` name: 2-50 chars
|
||||
- `/tag create` content: 10-2000 chars
|
||||
- `/tag edit` content: 10-2000 chars
|
||||
- `/response create` name: 2-50 chars
|
||||
- `/response create` content: 10-2000 chars
|
||||
- `/response edit` content: 10-2000 chars
|
||||
- `/panel` title: 5-100 chars
|
||||
- `/panel` description: 10-500 chars
|
||||
- `/search` query: 2-100 chars
|
||||
@@ -106,18 +106,18 @@ All 12 improvements have been successfully implemented!
|
||||
**What:** Related commands organized under one parent command
|
||||
|
||||
**Before:**
|
||||
- `/tag` - Send tag
|
||||
- `/tag-create` - Create tag
|
||||
- `/tag-edit` - Edit tag
|
||||
- `/tag-delete` - Delete tag
|
||||
- `/tag-list` - List tags
|
||||
- `/response send` - Send saved response
|
||||
- `/response create` - Create saved response
|
||||
- `/response edit` - Edit saved response
|
||||
- `/response delete` - Delete saved response
|
||||
- `/response list` - List saved responses
|
||||
|
||||
**After:**
|
||||
- `/tag send` - Send tag
|
||||
- `/tag create` - Create tag
|
||||
- `/tag edit` - Edit tag
|
||||
- `/tag delete` - Delete tag
|
||||
- `/tag list` - List tags
|
||||
- `/response send` - Send saved response
|
||||
- `/response create` - Create saved response
|
||||
- `/response edit` - Edit saved response
|
||||
- `/response delete` - Delete saved response
|
||||
- `/response list` - List saved responses
|
||||
|
||||
**Benefits:**
|
||||
- 5 commands → 1 command
|
||||
@@ -276,7 +276,7 @@ getAnalyticsSummary() // Returns detailed stats
|
||||
|
||||
**Console Output:**
|
||||
```
|
||||
📊 Analytics: commands/tag by User#1234
|
||||
📊 Analytics: commands/response by User#1234
|
||||
📊 Analytics: buttons/priority-select by User#5678
|
||||
```
|
||||
|
||||
@@ -332,7 +332,7 @@ getAnalyticsSummary() // Returns detailed stats
|
||||
**Loading States (deferReply):**
|
||||
- `/search` - Shows "thinking" while searching
|
||||
- `/stats` - Shows "thinking" while calculating
|
||||
- `/tag list` - Shows "thinking" while fetching
|
||||
- `/response list` - Shows "thinking" while fetching
|
||||
- Context menu commands - Always deferred
|
||||
- Modal submissions - Always deferred
|
||||
|
||||
@@ -355,7 +355,7 @@ getAnalyticsSummary() // Returns detailed stats
|
||||
| **Slash Commands** | 13 (was 15, now 13 due to grouping) |
|
||||
| **Context Menu Commands** | 2 (new!) |
|
||||
| **Total Commands** | 15 |
|
||||
| **Subcommands** | 5 (under `/tag`) |
|
||||
| **Subcommands** | 5 (under `/response`) |
|
||||
| **New Buttons** | 6 (3 priority + 2 confirm/cancel + tag delete) |
|
||||
| **New Functions** | 5+ (analytics, tracking, thread creation) |
|
||||
| **Lines of Code Added** | ~800+ |
|
||||
@@ -442,7 +442,7 @@ THREAD_PARENT_CHANNEL=
|
||||
|
||||
**Use a Saved Response:**
|
||||
```
|
||||
/tag send welcome
|
||||
/response send welcome
|
||||
```
|
||||
(Autocomplete shows all tags!)
|
||||
|
||||
@@ -574,7 +574,7 @@ THREAD_PARENT_CHANNEL=<your_channel_id>
|
||||
```
|
||||
/search query:test
|
||||
/stats
|
||||
/tag list
|
||||
/response list
|
||||
Right-click message → Create Ticket
|
||||
```
|
||||
|
||||
@@ -328,7 +328,7 @@ await interaction.reply({
|
||||
- ✅ Permission checks before operations
|
||||
- ✅ Role validation
|
||||
- ✅ Input validation
|
||||
- ✅ SQL parameterization
|
||||
- ✅ Parameterized queries
|
||||
|
||||
4. **Performance**
|
||||
- ✅ Efficient database queries
|
||||
@@ -559,7 +559,7 @@ client.on('interactionCreate', async interaction => {
|
||||
- [Discord.js Guide](https://discordjs.guide/)
|
||||
|
||||
**Our Implementation Files:**
|
||||
- `zammad-discord.js` - Main bot implementation
|
||||
- `broccolini-discord.js` - Main bot implementation
|
||||
- `PHASE_FEATURES.md` - Feature documentation
|
||||
- `QUICKSTART.md` - Quick start guide
|
||||
|
||||
73
docs/ENV_AND_SECURITY.md
Normal file
73
docs/ENV_AND_SECURITY.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Environment, Test Env, and Security
|
||||
|
||||
## Test environment (prevent data loss)
|
||||
|
||||
**.env is production/live.** Changes to `.env` can affect real tickets, Discord, Gmail, and MongoDB. To try config changes safely:
|
||||
|
||||
1. **Copy the test template:**
|
||||
`cp .env.test.example .env.test`
|
||||
|
||||
2. **Edit `.env.test`** with test-only values (e.g. test guild, test MongoDB database name, test API URL). Use a separate test DB in `MONGODB_URI` to avoid touching production data.
|
||||
|
||||
3. **Run the bot with the test env:**
|
||||
`npm run start:test`
|
||||
Or: `ENV_FILE=.env.test node broccolini-discord.js`
|
||||
|
||||
4. **Other scripts with test env:**
|
||||
- `npm run test-mongodb:test` — test MongoDB connection using `.env.test`
|
||||
|
||||
5. **After confirming behavior**, migrate only the desired variables from `.env.test` into `.env` (manually). Do not overwrite `.env` blindly.
|
||||
|
||||
**Rule:** New or risky env changes are done in `.env.test` first; only after confirmation are they applied to `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Agent / AI rules
|
||||
|
||||
- **Changes to `.env` by an agent (e.g. Cursor) must require explicit user confirmation.** Do not modify `.env` automatically. Prefer proposing changes to `.env.test` or listing the exact edits for the user to apply to `.env`.
|
||||
- **Do not commit `.env` or `.env.test`.** Only `.env.example` and `.env.test.example` are committed (no secrets).
|
||||
|
||||
---
|
||||
|
||||
## Security checklist
|
||||
|
||||
- **Secrets:** All secrets live in `.env` (or `.env.test` for test). Never commit them. `.gitignore` excludes `.env` and `.env.*` except `.env.example` and `.env.test.example`.
|
||||
- **Code:** No `eval()` or `new Function()` of user input. No hardcoded tokens, passwords, or API keys in source.
|
||||
- **Config:** Credentials are read from `process.env` via `config.js`; config is loaded once at startup from the file specified by `ENV_FILE` or default `.env`.
|
||||
- **MongoDB:** Use a dedicated user and database; restrict network access (Atlas IP allowlist or VPC). For test, use a separate DB or cluster.
|
||||
- **Discord / Google:** Use tokens with minimal required scopes; rotate if compromised.
|
||||
- **HTML in emails:** `LOGO_URL`, `EMAIL_SIGNATURE`, and closure messages are escaped in outbound HTML to prevent injection.
|
||||
- **Healthcheck:** Optional `HEALTHCHECK_HOST=127.0.0.1` in `.env` binds the healthcheck server to localhost only; omit to listen on all interfaces.
|
||||
- **Dependencies:** Run `npm audit` periodically and fix or accept risk for reported vulnerabilities.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup and redundancy
|
||||
|
||||
- **Single source of truth for env keys:** `.env.example` and `.env.test.example` list all supported variables. Defaults for optional vars live in `config.js`; do not duplicate default values in both `.env.example` and `config.js` for the same value (`.env.example` documents, `config.js` implements).
|
||||
- **No duplicate env files:** Use `.env` for live, `.env.test` for test; do not commit `.env.local`, `.env.production`, etc. unless documented and gitignored as needed.
|
||||
- **Parent repo (IB-Discord-Bot):** Broccolini Bot does not reference sibling paths (e.g. `../ngrok`) in code. Run order and ports are documented in `~/IB-Discord-Bot/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Connection to IB-Discord-Bot stack
|
||||
|
||||
Broccolini Bot is a subproject of **IB-Discord-Bot**. It does not import or require files outside `broccolini-bot/`. Integration is via:
|
||||
|
||||
- **Ports:** Broccolini Bot healthcheck uses `DISCORD_ONLY_PORT` (default 5000). Use a different port in `.env.test` (e.g. 5001) if running bot and test bot on the same machine.
|
||||
|
||||
See parent **~/IB-Discord-Bot/README.md** for run order, ports, and troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| File / command | Purpose |
|
||||
|-----------------------|--------|
|
||||
| `.env` | Live config (never commit). |
|
||||
| `.env.test` | Test config (never commit). |
|
||||
| `.env.example` | Template for `.env` (committed). |
|
||||
| `.env.test.example` | Template for `.env.test` (committed). |
|
||||
| `ENV_FILE=.env.test` | Load `.env.test` instead of `.env`. |
|
||||
| `npm run start:test` | Run bot with `.env.test`. |
|
||||
| `npm run test-mongodb:test` | Test MongoDB using `.env.test`. |
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🎉 New Features Summary
|
||||
|
||||
All requested features have been added to your Gmail-Discord-Zammad bridge!
|
||||
All requested features have been added to Broccolini Bot!
|
||||
|
||||
## ✅ What's New
|
||||
|
||||
@@ -59,17 +59,15 @@ Smart monitoring of ticket engagement
|
||||
## 🗂️ Files Modified
|
||||
|
||||
### Configuration
|
||||
- ✅ `.env` - Added 40+ new environment variables
|
||||
- ✅ `package.json` - Added helpful scripts
|
||||
- ✅ `.env` (repo root) - Added 40+ new environment variables
|
||||
- ✅ `package.json` - Scripts: `npm start`, `npm run test-mongodb`
|
||||
|
||||
### Code
|
||||
- ✅ `zammad-discord.js` - All features integrated
|
||||
- ✅ `broccolini-discord.js` - All features integrated
|
||||
- ✅ `models.js` - MongoDB schemas updated with new fields
|
||||
|
||||
### Database
|
||||
- ✅ SQLite schema updated (tickets table)
|
||||
- ✅ MongoDB schemas updated
|
||||
- ✅ Migration script created (`migrate-schema.js`)
|
||||
- ✅ MongoDB schemas (Mongoose) for tickets, tags, close requests, etc.
|
||||
|
||||
### Documentation
|
||||
- ✅ `NEW_FEATURES.md` - Detailed feature documentation
|
||||
@@ -78,15 +76,7 @@ Smart monitoring of ticket engagement
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Migrate Existing Database (If Applicable)
|
||||
If you have existing tickets in SQLite:
|
||||
```bash
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
This adds new columns: `zammad_ticket_id`, `priority`, `last_activity`, `reminder_sent`
|
||||
|
||||
### 2. Configure Features
|
||||
### 1. Configure Features
|
||||
All features are pre-configured in `.env` with sensible defaults. Adjust as needed:
|
||||
|
||||
**Essential Settings:**
|
||||
@@ -107,14 +97,14 @@ BUTTON_LABEL_CLOSE=Your custom label
|
||||
EMBED_COLOR_OPEN=0x00FF00
|
||||
```
|
||||
|
||||
### 3. Start the Bot
|
||||
### 2. Start the Bot
|
||||
```bash
|
||||
npm start
|
||||
# or
|
||||
node zammad-discord.js
|
||||
node broccolini-discord.js
|
||||
```
|
||||
|
||||
### 4. Verify Features
|
||||
### 3. Verify Features
|
||||
Watch the console on startup:
|
||||
```
|
||||
✓ Auto-close enabled: checking every hour
|
||||
@@ -134,8 +124,8 @@ Watch the console on startup:
|
||||
| Button Customization | ✅ Working | Yes |
|
||||
| Embed Colors | ✅ Working | Yes |
|
||||
| Activity Tracking | ✅ Working | Yes (automatic) |
|
||||
| Priority Levels | 🟡 Backend Only | Needs UI (slash command) |
|
||||
| Modal Forms | 🟡 Framework Only | Needs full implementation |
|
||||
| Priority Levels | ✅ Working | Use `/priority` slash command |
|
||||
| Modal Forms | ✅ Working | Panel "Open Ticket" → modal form |
|
||||
|
||||
## 🎯 Testing Your New Features
|
||||
|
||||
@@ -266,7 +256,7 @@ GLOBAL_TICKET_LIMIT=3 # Strict limit
|
||||
|
||||
3. **Test in Staging:** Test auto-close with a low hour value first (e.g., 1 hour)
|
||||
|
||||
4. **Backup Database:** Before running migration, backup `discord_only.sqlite`
|
||||
4. **Backup data:** Back up MongoDB if migrating or changing schema
|
||||
|
||||
5. **Customize Gradually:** Change one setting at a time to see the impact
|
||||
|
||||
@@ -302,34 +292,30 @@ GLOBAL_TICKET_LIMIT=3 # Strict limit
|
||||
## 📚 Next Steps
|
||||
|
||||
### Immediate (Ready to Use):
|
||||
1. ✅ Run `npm run migrate` if you have existing tickets
|
||||
2. ✅ Adjust settings in `.env` to your preferences
|
||||
3. ✅ Restart bot with `npm start`
|
||||
4. ✅ Test each feature
|
||||
5. ✅ Monitor for a few days
|
||||
1. ✅ Adjust settings in `.env` (repo root) to your preferences
|
||||
2. ✅ Restart bot with `npm start`
|
||||
3. ✅ Test each feature
|
||||
4. ✅ Monitor for a few days
|
||||
|
||||
### Short Term (Needs Implementation):
|
||||
6. 🔨 Add `/priority` slash command for setting ticket priority
|
||||
7. 🔨 Display priority emoji in ticket embeds
|
||||
8. 🔨 Add filter by priority in ticket queries
|
||||
### Short Term (Optional):
|
||||
5. Display priority emoji in ticket embeds (already set via `/priority`)
|
||||
6. Add filter by priority in ticket queries
|
||||
|
||||
### Medium Term (Future Enhancement):
|
||||
9. 🔨 Implement modal forms for Discord-side ticket creation
|
||||
10. 🔨 Add email notifications when ticket limits reached
|
||||
11. 🔨 Enforce blacklisted roles in all interactions
|
||||
12. 🔨 Add statistics dashboard for auto-close/reminder metrics
|
||||
7. Add email notifications when ticket limits reached
|
||||
8. Enforce blacklisted roles in all interactions
|
||||
9. Add statistics dashboard for auto-close/reminder metrics
|
||||
|
||||
### Long Term (From Original Plan):
|
||||
13. 📊 Migrate to MongoDB (schemas already ready!)
|
||||
14. 🧪 Add unit tests for new features
|
||||
15. 🐳 Docker integration
|
||||
16. 📈 Production monitoring and alerts
|
||||
12. 🧪 Add unit tests for new features
|
||||
13. 🐳 Docker integration
|
||||
14. 📈 Production monitoring and alerts
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions or issues with the new features, check:
|
||||
- `NEW_FEATURES.md` - Detailed documentation
|
||||
- `migrate-schema.js` - Database migration tool
|
||||
- `models.js` - MongoDB (Mongoose) schemas
|
||||
- Console logs - Watch for error messages
|
||||
- GitHub Issues - Report bugs or request features
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented **50+ new features** across 5 phases, transforming the ticket system into a comprehensive support platform.
|
||||
Successfully implemented **50+ new features** across 5 phases, transforming the ticket system into a comprehensive support platform. The project is a **single-level repo** (run from repo root) and uses **MongoDB only** (Mongoose); the schema notes below describe the logical structure implemented in `models.js`.
|
||||
|
||||
---
|
||||
|
||||
@@ -53,34 +53,14 @@ Successfully implemented **50+ new features** across 5 phases, transforming the
|
||||
|
||||
## 🗄️ Database Changes
|
||||
|
||||
### New Tables
|
||||
```sql
|
||||
-- Tags system
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER,
|
||||
created_by TEXT,
|
||||
use_count INTEGER DEFAULT 0
|
||||
);
|
||||
The project uses **MongoDB (Mongoose)**. The following describes the logical schema; see `models.js` for the actual Mongoose schemas.
|
||||
|
||||
-- Close confirmation tracking
|
||||
CREATE TABLE close_requests (
|
||||
ticket_id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
### New collections / models
|
||||
- **Tag** – Saved responses (name, content, creator, use count).
|
||||
- **CloseRequest** – Tracks pending close confirmations (ticket ID, requested by, reason).
|
||||
|
||||
### Modified Tables
|
||||
```sql
|
||||
-- Added to tickets table:
|
||||
priority TEXT DEFAULT 'normal'
|
||||
last_activity INTEGER
|
||||
reminder_sent INTEGER DEFAULT 0
|
||||
```
|
||||
### Modified Ticket model
|
||||
- Added fields: `priority`, `last_activity`, `reminder_sent` (and related ticket lifecycle fields).
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +79,7 @@ reminder_sent INTEGER DEFAULT 0
|
||||
- `/escalate [reason] [tier]` - Escalate to tier 2 or 3 (optional tier)
|
||||
- `/deescalate` - De-escalate one step
|
||||
|
||||
### Tags System (5 commands)
|
||||
### Tags & Saved Responses
|
||||
- `/tag` - Set ticket category (dropdown); posts categorization message (no channel rename)
|
||||
- `/response send|create|edit|delete|list` - Saved response templates
|
||||
|
||||
@@ -158,9 +138,9 @@ THREAD_PARENT_CHANNEL=
|
||||
- 🟢 Low Priority (green embeds)
|
||||
|
||||
### Autocomplete Support
|
||||
- Tag names in `/tag` command
|
||||
- Tag names in `/tag edit` command
|
||||
- Tag names in `/tag delete` command
|
||||
- Saved response names in `/response send` (autocomplete)
|
||||
- Response names in `/response edit` command
|
||||
- Response names in `/response delete` command
|
||||
|
||||
---
|
||||
|
||||
@@ -170,7 +150,7 @@ THREAD_PARENT_CHANNEL=
|
||||
- Checks tickets older than configured hours
|
||||
- Closes automatically with message
|
||||
- Generates transcripts
|
||||
- Updates Zammad
|
||||
- Updates the external ticket API (if configured)
|
||||
|
||||
### Auto-Unclaim (Every Hour)
|
||||
- Checks claimed tickets inactive beyond threshold
|
||||
@@ -244,9 +224,9 @@ THREAD_PARENT_CHANNEL=
|
||||
- Database transaction safety
|
||||
|
||||
### Prevented Issues
|
||||
- SQL injection (parameterized queries)
|
||||
- Injection (Mongoose validation and parameterized usage)
|
||||
- XSS in modal inputs (validation)
|
||||
- Duplicate tag creation (UNIQUE constraint)
|
||||
- Duplicate tag creation (Mongoose unique index)
|
||||
- Invalid priority values (validation)
|
||||
- Race conditions (proper locking)
|
||||
|
||||
@@ -284,7 +264,7 @@ THREAD_PARENT_CHANNEL=
|
||||
- Tag names (alphanumeric, length limits)
|
||||
- Priority values (enum validation)
|
||||
- Modal input sanitization
|
||||
- SQL parameterization
|
||||
- Mongoose schema validation
|
||||
|
||||
### Error Handling
|
||||
- Graceful failures
|
||||
@@ -378,7 +358,7 @@ THREAD_PARENT_CHANNEL=
|
||||
- Database migrations need planning
|
||||
|
||||
### Best Practices Applied
|
||||
- Parameterized SQL queries
|
||||
- Mongoose queries (no raw string concatenation)
|
||||
- Clear error messages
|
||||
- Comprehensive logging
|
||||
- Graceful degradation
|
||||
@@ -417,7 +397,7 @@ USE_THREADS=false
|
||||
npm start
|
||||
```
|
||||
|
||||
Database tables auto-create on startup.
|
||||
MongoDB collections are created as needed on startup.
|
||||
|
||||
#### 4. Register Commands
|
||||
Commands auto-register on bot ready event.
|
||||
@@ -1,17 +1,17 @@
|
||||
# MongoDB Setup for Gmail-Discord-Zammad Bridge
|
||||
# MongoDB Setup for Broccolini Bot
|
||||
|
||||
## Overview
|
||||
|
||||
The bridge uses **MongoDB only** for persistent storage (tickets, transcripts, counters, tags, close requests). SQLite has been removed; a backup of the old SQLite-based code and schema lives in `backup-sqlite/`. To migrate existing SQLite data to MongoDB, run `npm run migrate-from-sqlite` (see that script’s prerequisites).
|
||||
Broccolini Bot uses **MongoDB only** for persistent storage (tickets, transcripts, counters, tags, close requests). Run all commands from the repo root; create `.env` there (copy from `.env.example`) and set `MONGODB_URI`. For test runs, use `.env.test` (copy from `.env.test.example`) and `npm run test-mongodb:test`; see [ENV_AND_SECURITY.md](./ENV_AND_SECURITY.md).
|
||||
|
||||
## Files Created
|
||||
## Files
|
||||
|
||||
1. **`db-connection.js`** - MongoDB connection module with reconnection logic
|
||||
2. **`models.js`** - Updated with three new schemas:
|
||||
2. **`models.js`** - Mongoose schemas including:
|
||||
- `Ticket` - Stores ticket information
|
||||
- `TicketCounter` - Tracks ticket numbers per sender
|
||||
- `Transcript` - Stores transcript message references
|
||||
3. **`mongodb-example.js`** - Example usage patterns
|
||||
3. **`scripts/test-mongodb.js`** - Connection test script (run via `npm run test-mongodb`; use `npm run test-mongodb:test` with `.env.test`)
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -20,10 +20,10 @@ The bridge uses **MongoDB only** for persistent storage (tickets, transcripts, c
|
||||
Add to your `.env` file:
|
||||
|
||||
```env
|
||||
MONGODB_URI=mongodb://localhost:27018/indifferent_broccoli
|
||||
MONGODB_URI=mongodb://localhost:27018/broccolini_bot
|
||||
```
|
||||
|
||||
**Note:** Uses port `27018` to match your existing Indifferent Broccoli setup (as defined in docker-compose.yml).
|
||||
**Note:** Uses port `27018` to match your existing setup (as defined in docker-compose.yml).
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
@@ -50,49 +50,6 @@ const TicketCounter = mongoose.model('TicketCounter');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
```
|
||||
|
||||
### Replacing SQLite Operations
|
||||
|
||||
#### Old (SQLite):
|
||||
```javascript
|
||||
const ticket = await dbGet("SELECT * FROM tickets WHERE gmail_thread_id = ?", [threadId]);
|
||||
```
|
||||
|
||||
#### New (MongoDB):
|
||||
```javascript
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const ticket = await Ticket.findOne({ gmail_thread_id: threadId });
|
||||
```
|
||||
|
||||
#### Old (SQLite):
|
||||
```javascript
|
||||
await dbRun("INSERT INTO tickets (gmail_thread_id, discord_thread_id, ...) VALUES (?, ?, ...)",
|
||||
[threadId, channelId, ...]);
|
||||
```
|
||||
|
||||
#### New (MongoDB):
|
||||
```javascript
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
await Ticket.create({
|
||||
gmail_thread_id: threadId,
|
||||
discord_thread_id: channelId,
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
#### Old (SQLite):
|
||||
```javascript
|
||||
await dbRun("UPDATE tickets SET status = ? WHERE gmail_thread_id = ?", ['closed', threadId]);
|
||||
```
|
||||
|
||||
#### New (MongoDB):
|
||||
```javascript
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
await Ticket.updateOne(
|
||||
{ gmail_thread_id: threadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
```
|
||||
|
||||
## Schema Reference
|
||||
|
||||
### Ticket Schema
|
||||
@@ -101,7 +58,7 @@ await Ticket.updateOne(
|
||||
{
|
||||
gmail_thread_id: String (required, unique, indexed),
|
||||
discord_thread_id: String,
|
||||
zammad_ticket_id: Number,
|
||||
broccolini_ticket_id: Number,
|
||||
sender_email: String (required),
|
||||
subject: String,
|
||||
created_at: Date (default: now),
|
||||
@@ -135,22 +92,15 @@ await Ticket.updateOne(
|
||||
|
||||
## Testing the Connection
|
||||
|
||||
Run the example file to verify everything works:
|
||||
From the repo root, run:
|
||||
|
||||
```bash
|
||||
node mongodb-example.js
|
||||
npm run test-mongodb
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Connecting to MongoDB...
|
||||
✓ Connected to MongoDB
|
||||
✓ Models loaded: Ticket, TicketCounter, Transcript
|
||||
|
||||
--- Example: Create Ticket ---
|
||||
Created ticket: example_thread_123
|
||||
...
|
||||
✓ Example completed successfully
|
||||
Pinged your deployment. You successfully connected to MongoDB!
|
||||
```
|
||||
|
||||
## Graceful Shutdown
|
||||
@@ -173,12 +123,6 @@ process.on('SIGINT', async () => {
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- **No automatic data migration** from SQLite - starting fresh with MongoDB
|
||||
- The existing `tickets.sqlite` and `discord_only.sqlite` files remain untouched
|
||||
- You can manually export data from SQLite and import into MongoDB if needed
|
||||
|
||||
## Connection Features
|
||||
|
||||
- **Auto-reconnection**: If MongoDB connection drops, Mongoose will automatically attempt to reconnect
|
||||
@@ -188,9 +132,9 @@ process.on('SIGINT', async () => {
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review the schemas in `models.js` (lines 793-819)
|
||||
2. Test the connection with `node mongodb-example.js`
|
||||
3. Start replacing SQLite operations in `zammad-discord.js` with MongoDB operations
|
||||
1. Review the schemas in `models.js`
|
||||
2. Test the connection with `npm run test-mongodb`
|
||||
3. Start the bot with `npm start` (uses MongoDB throughout)
|
||||
4. Monitor MongoDB connection in production logs
|
||||
|
||||
## Troubleshooting
|
||||
@@ -201,7 +145,7 @@ process.on('SIGINT', async () => {
|
||||
- Check MongoDB logs for errors
|
||||
|
||||
### Authentication failed
|
||||
- If MongoDB requires auth, update URI: `mongodb://username:password@localhost:27018/indifferent_broccoli`
|
||||
- If MongoDB requires auth, update URI: `mongodb://username:password@localhost:27018/broccolini_bot`
|
||||
|
||||
### Schema validation errors
|
||||
- Check required fields are provided when creating documents
|
||||
@@ -1,102 +0,0 @@
|
||||
# MongoDB Bridge ↔ Zammad Schema Mapping
|
||||
|
||||
Reference for linking the gmail-bridge (MongoDB) with Zammad's PostgreSQL schema. Source: [schema zammad.txt](schema%20zammad.txt).
|
||||
|
||||
## Primary link: Ticket ↔ Zammad ticket
|
||||
|
||||
| MongoDB (Ticket) | Zammad (tickets) | Notes |
|
||||
|--------------------|------------------|--------|
|
||||
| `zammadTicketId` | `id` (serial) | Primary link; set when we create a Zammad ticket via API. |
|
||||
| `gmailThreadId` | — | Bridge-only; Gmail thread ID. |
|
||||
| `discordThreadId` | — | Bridge-only; Discord channel ID. |
|
||||
| `senderEmail` | `customer_id` → users.email | Zammad creates/finds user by email when we POST ticket. |
|
||||
| `subject` | `title` | Ticket title. |
|
||||
| `status` (open/closed) | `state_id` → ticket_states | We PATCH state to closed on force-close. |
|
||||
|
||||
## Zammad tables we touch via API
|
||||
|
||||
- **tickets** – Create (POST), update state (PATCH). Key columns: `id`, `group_id`, `priority_id`, `state_id`, `title`, `customer_id`, `owner_id`, `number`, `discordusername`, `gameid`.
|
||||
- **ticket_articles** – Create (POST) when staff reply in Discord. Key columns: `ticket_id`, `type_id` (e.g. note), `sender_id`, `body`, `content_type`, `internal`, `subject`, `from`.
|
||||
- **users** – Zammad creates/finds customer by email on ticket create. If the sender email matches a MongoDB **User** (website user) with `discordID`, the bridge PATCHes the Zammad user with `discord_id` so the customer in Zammad has the Discord ID. Requires adding a custom attribute **discord_id** on the **User** object in Zammad (Admin → Object Manager → User).
|
||||
|
||||
## Zammad custom fields on tickets (from schema)
|
||||
|
||||
- **discordusername** (limit 120) – Can store Discord display name for the ticket.
|
||||
- **gameid** (limit 255) – We already send this when creating a ticket (game key from bridge).
|
||||
|
||||
## Important MongoDB fields → Zammad custom attributes
|
||||
|
||||
These MongoDB Ticket fields matter for Zammad; the schema only has two custom columns on `tickets`, so we map into those or would need new attributes in Zammad.
|
||||
|
||||
| MongoDB (Ticket) | Zammad today | Action |
|
||||
|-------------------|--------------|--------|
|
||||
| **gameKey** (from email/game) | **gameid** | Already sent on ticket create. |
|
||||
| **claimedBy** (Discord display name) | **discordusername** | Not set today. Should PATCH ticket when someone claims (e.g. set `discordusername` to `claimedBy` on claim, clear on unclaim). |
|
||||
| **priority** (low/normal/medium/high) | **priority_id** (core) | Not synced. We could PATCH ticket when `/priority` is used so Zammad shows same priority. |
|
||||
| **gmailThreadId** | — | No column in Zammad. If you add a custom attribute (e.g. `gmail_thread_id`) in Zammad, we can send it on create/update. |
|
||||
| **discordThreadId** | — | No column in Zammad. Same: add e.g. `discord_channel_id` in Zammad if you want it there. |
|
||||
| **escalated** | — | No column in Zammad. Add a custom attribute (e.g. `escalated` boolean) in Zammad if you want it visible in Zammad. |
|
||||
|
||||
**Recommended now (no Zammad schema change):**
|
||||
|
||||
1. **Set `discordusername` on claim** – When a staff member claims the ticket in Discord, PATCH the Zammad ticket with `discordusername: claimedBy` so Zammad shows who is handling it on Discord. Clear it on unclaim.
|
||||
2. **Sync priority to Zammad** – When priority changes in Discord via `/priority`, PATCH the Zammad ticket with the matching priority (e.g. Zammad uses `1` low, `2` normal, `3` high or similar; check your Zammad priority_id values).
|
||||
|
||||
**Optional (requires adding custom attributes in Zammad):**
|
||||
|
||||
- **gmail_thread_id** – Useful for agents to open or reference the Gmail thread.
|
||||
- **discord_channel_id** – Useful for deep links to the Discord thread.
|
||||
- **escalated** – If you want Zammad to show that the ticket was escalated from Discord.
|
||||
|
||||
## Email ticket → Zammad user with discord_id
|
||||
|
||||
When an email ticket comes in, the bridge:
|
||||
|
||||
1. Extracts sender email (`sEmail`).
|
||||
2. Looks up **MongoDB User** (website user) by `email` (case-insensitive). If that user has a `discordID`, it is stored for the next step.
|
||||
3. Creates the Zammad ticket (Zammad creates or finds the customer user by email).
|
||||
4. If the ticket response has `customer_id` and we have a `discordID` from step 2, the bridge **PATCHes the Zammad user** with `discord_id: discordID`.
|
||||
|
||||
**Requirement:** In Zammad, add a custom attribute **discord_id** (text) on the **User** object: Admin → Object Manager → User → add attribute `discord_id`. Without it, the PATCH will fail (the bridge logs the error and continues).
|
||||
|
||||
## Discord ticket → ensure Zammad user exists
|
||||
|
||||
When a **Discord ticket** is created (modal “Create Support Ticket” or “Create ticket from message”):
|
||||
|
||||
1. The bridge looks up **MongoDB User** (website user) by the creator’s Discord ID (`interaction.user.id` or `message.author.id`).
|
||||
2. If a User is found with an **email**, the bridge calls **ensureZammadUserForDiscordUser**:
|
||||
- Searches Zammad for a user with that email (`GET /api/v1/users/search?query=...`).
|
||||
- If none exists, **creates** a Zammad user (Customer) with email, firstname, lastname, and `discord_id`.
|
||||
- If a user exists, optionally sets `discord_id` on them via PATCH.
|
||||
3. No Zammad **ticket** is created for Discord-only tickets; only the Zammad **user** is ensured so they exist when you later create tickets or link them.
|
||||
|
||||
**Requirement:** Same as above: add **discord_id** on the User object in Zammad Object Manager if you want Discord ID stored. User create will still work without it; only the `discord_id` field will be skipped.
|
||||
|
||||
## Flow summary
|
||||
|
||||
1. **New email → new ticket**
|
||||
Bridge creates MongoDB `Ticket` and calls Zammad `POST /api/v1/tickets`; stores returned `id` in `Ticket.zammadTicketId`.
|
||||
2. **Staff reply in Discord**
|
||||
Bridge sends reply to Gmail and calls Zammad `POST /api/v1/ticket_articles` with `ticket_id: ticket.zammadTicketId`.
|
||||
3. **Force-close in Discord**
|
||||
Bridge sets MongoDB `status: 'closed'` and calls Zammad `PATCH /api/v1/tickets/:id` with `state: 'closed'`.
|
||||
|
||||
## Creating Zammad objects to match the bridge
|
||||
|
||||
To ensure Zammad has the groups (and reference data) the bridge expects, run from `gmail-bridge`:
|
||||
|
||||
```bash
|
||||
npm run create-zammad-objects
|
||||
```
|
||||
|
||||
This script (see [scripts/create-zammad-objects.js](../scripts/create-zammad-objects.js)) uses the same `.env` as the bridge and:
|
||||
|
||||
- Creates the groups **Email Users** and **Discord Users** if they do not exist (from `ZAMMAD_EMAIL_GROUP` and `ZAMMAD_DISCORD_GROUP`).
|
||||
- Lists **ticket priorities** and **ticket states** so you can verify IDs (e.g. for future priority sync).
|
||||
|
||||
Custom attributes (e.g. `gmail_thread_id`, `discord_channel_id`, `escalated`) must be added in Zammad’s admin UI (Object Manager) if you want them; the API for creating object attributes is admin-only.
|
||||
|
||||
## Optional improvements
|
||||
|
||||
- **Store Zammad customer id** – If we ever need to reference the same customer across tickets, add `zammadCustomerId` to MongoDB Ticket and set it from the Zammad ticket create response.
|
||||
- **Set discordusername on ticket** – When adding an article from Discord, we could PATCH the ticket to set `discordusername` to the replier’s display name (if Zammad API supports updating that field).
|
||||
@@ -1,7 +1,7 @@
|
||||
# New Features Added to Gmail-Discord-Zammad Bridge
|
||||
# New Features Added to Broccolini Bot
|
||||
|
||||
## Overview
|
||||
This document summarizes the new features added to enhance the ticket management system.
|
||||
This document summarizes the new features added to enhance the ticket management system. Run all commands from the repo root; `.env` lives in the repo root (copy from `.env.example`).
|
||||
|
||||
## ✅ Features Implemented
|
||||
|
||||
@@ -97,7 +97,7 @@ PRIORITY_LOW_EMOJI=🟢
|
||||
```
|
||||
|
||||
**Database:**
|
||||
- Added `priority` column to tickets table (default: 'normal')
|
||||
- Added `priority` field to Ticket model (MongoDB; default: 'normal')
|
||||
|
||||
**Helper Functions:**
|
||||
- `getPriorityEmoji(priority)` - Returns emoji for priority level (low, normal, medium, high)
|
||||
@@ -209,28 +209,8 @@ if (interaction.commandName === 'ticket-create') {
|
||||
|
||||
## 📊 Database Schema Updates
|
||||
|
||||
### Tickets Table - New Columns:
|
||||
```sql
|
||||
zammad_ticket_id INTEGER -- Now in schema (was missing)
|
||||
priority TEXT DEFAULT 'normal' -- For priority levels
|
||||
last_activity INTEGER -- Timestamp of last message
|
||||
reminder_sent INTEGER DEFAULT 0 -- Boolean flag for reminder status
|
||||
```
|
||||
|
||||
### Migration:
|
||||
If you have existing SQLite database, run:
|
||||
```sql
|
||||
ALTER TABLE tickets ADD COLUMN zammad_ticket_id INTEGER;
|
||||
ALTER TABLE tickets ADD COLUMN priority TEXT DEFAULT 'normal';
|
||||
ALTER TABLE tickets ADD COLUMN last_activity INTEGER;
|
||||
ALTER TABLE tickets ADD COLUMN reminder_sent INTEGER DEFAULT 0;
|
||||
```
|
||||
|
||||
Or delete `tickets.sqlite` and `discord_only.sqlite` to start fresh (recommended).
|
||||
|
||||
### MongoDB Schema:
|
||||
The MongoDB schemas in `models.js` already include all these fields:
|
||||
- ✅ `zammad_ticket_id`
|
||||
The **Ticket** model in `models.js` (MongoDB/Mongoose) includes these fields:
|
||||
- ✅ `broccolini_ticket_id`
|
||||
- ✅ `priority`
|
||||
- ✅ `last_activity`
|
||||
- ✅ `reminder_sent`
|
||||
@@ -1,4 +1,4 @@
|
||||
# Gmail-Discord-Zammad Bridge - New Features Documentation
|
||||
# Broccolini Bot - New Features Documentation
|
||||
|
||||
This document outlines all the features implemented in the latest update.
|
||||
|
||||
@@ -34,30 +34,18 @@ A powerful template system for dynamic messages using placeholders.
|
||||
Create, manage, and use saved responses for common questions.
|
||||
|
||||
**Commands:**
|
||||
- `/tag send <name>` - Send a saved response
|
||||
- `/tag create <name> <content>` - Create new tag
|
||||
- `/tag edit <name> <content>` - Edit existing tag
|
||||
- `/tag delete <name>` - Delete a tag
|
||||
- `/tag list` - List all available tags
|
||||
- `/tag set <tag>` - Set ticket category tag (dropdown: Server Down, Billing, Mod Help, etc.); renames channel (priority emoji first, then tag emoji)
|
||||
- `/response send <name>` - Send a saved response
|
||||
- `/response create <name> <content>` - Create new saved response
|
||||
- `/response edit <name> <content>` - Edit existing saved response
|
||||
- `/response delete <name>` - Delete a saved response
|
||||
- `/response list` - List all saved responses
|
||||
- `/tag` - Set ticket category (dropdown: Server Down, Billing, Mod Help, etc.); posts categorization message (channel name unchanged)
|
||||
|
||||
**Features:**
|
||||
- Autocomplete support for tag names
|
||||
- Autocomplete support for response names
|
||||
- Usage counter tracking
|
||||
- Supports variable substitution
|
||||
- Database-backed persistence
|
||||
|
||||
**Database Table:**
|
||||
```sql
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000),
|
||||
created_by TEXT,
|
||||
use_count INTEGER DEFAULT 0
|
||||
);
|
||||
```
|
||||
- Database-backed persistence (MongoDB via Mongoose `Tag` model; see `models.js`)
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +104,7 @@ Force close a ticket without confirmation.
|
||||
|
||||
**Features:**
|
||||
- Generates transcript
|
||||
- Updates Zammad (if configured)
|
||||
- Updates the external ticket API (if configured)
|
||||
- Archives channel after 5 seconds
|
||||
- No confirmation required
|
||||
|
||||
@@ -131,16 +119,7 @@ When clicking the "Close Ticket" button, users now see a confirmation prompt.
|
||||
3. User clicks "Confirm Close" or "Cancel"
|
||||
4. If confirmed, ticket closes as usual
|
||||
|
||||
**Database Table:**
|
||||
```sql
|
||||
CREATE TABLE close_requests (
|
||||
ticket_id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(gmail_thread_id)
|
||||
);
|
||||
```
|
||||
**Storage:** Close requests are stored in MongoDB. See `models.js` for schema.
|
||||
|
||||
---
|
||||
|
||||
@@ -321,52 +300,9 @@ A framework for creating custom automation rules.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Updates
|
||||
## Database Schema
|
||||
|
||||
### Tickets Table
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
gmail_thread_id TEXT PRIMARY KEY,
|
||||
discord_thread_id TEXT,
|
||||
sender_email TEXT,
|
||||
subject TEXT,
|
||||
created_at INTEGER,
|
||||
status TEXT DEFAULT 'open',
|
||||
transcript_message_id TEXT,
|
||||
claimed_by TEXT,
|
||||
escalated INTEGER DEFAULT 0,
|
||||
ticket_number INTEGER,
|
||||
rename_count INTEGER DEFAULT 0,
|
||||
rename_window_start INTEGER DEFAULT 0,
|
||||
zammad_ticket_id INTEGER,
|
||||
priority TEXT DEFAULT 'normal',
|
||||
last_activity INTEGER,
|
||||
reminder_sent INTEGER DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
### Tags Table
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now') * 1000),
|
||||
created_by TEXT,
|
||||
use_count INTEGER DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
### Close Requests Table
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS close_requests (
|
||||
ticket_id TEXT PRIMARY KEY,
|
||||
requested_by TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(gmail_thread_id)
|
||||
);
|
||||
```
|
||||
The project uses MongoDB. Ticket, tag, and close-request data are defined in `models.js`. See that file and `MONGODB_SETUP.md` for schema reference.
|
||||
|
||||
---
|
||||
|
||||
@@ -404,12 +340,13 @@ THREAD_PARENT_CHANNEL=
|
||||
- `/escalate [reason] [tier]` - Escalate ticket to tier 2 or 3
|
||||
- `/deescalate` - De-escalate ticket
|
||||
|
||||
### Tags System
|
||||
- `/tag send <name>` - Send saved response
|
||||
- `/tag create <name> <content>` - Create new tag
|
||||
- `/tag edit <name> <content>` - Edit existing tag
|
||||
- `/tag delete <name>` - Delete tag
|
||||
- `/tag list` - List all tags
|
||||
### Tags / Saved Responses
|
||||
- `/response send <name>` - Send saved response
|
||||
- `/response create <name> <content>` - Create new saved response
|
||||
- `/response edit <name> <content>` - Edit existing saved response
|
||||
- `/response delete <name>` - Delete saved response
|
||||
- `/response list` - List all saved responses
|
||||
- `/tag` - Set ticket category (dropdown)
|
||||
|
||||
### Panel System
|
||||
- `/panel #channel [title] [description]` - Create ticket panel
|
||||
@@ -423,16 +360,14 @@ THREAD_PARENT_CHANNEL=
|
||||
|
||||
### From Previous Version
|
||||
|
||||
1. **Database Migration**: New columns added automatically on startup
|
||||
- priority
|
||||
- last_activity
|
||||
- reminder_sent
|
||||
1. **Database**: New Mongoose schema fields used on startup (collections created as needed)
|
||||
- priority, last_activity, reminder_sent on Ticket
|
||||
|
||||
2. **New Tables**: Created automatically
|
||||
- tags
|
||||
- close_requests
|
||||
2. **New collections**: Created automatically by Mongoose
|
||||
- Tag (saved responses)
|
||||
- CloseRequest
|
||||
|
||||
3. **Environment Variables**: Add to `.env`:
|
||||
3. **Environment Variables**: Add to `.env` (repo root):
|
||||
```env
|
||||
CLAIM_TIMEOUT_ENABLED=false
|
||||
AUTO_UNCLAIM_ENABLED=false
|
||||
@@ -485,11 +420,10 @@ THREAD_PARENT_CHANNEL=
|
||||
- Check for Discord outages
|
||||
- Verify bot has proper permissions
|
||||
|
||||
### Tags Not Working
|
||||
- Check database file permissions
|
||||
- Verify SQLite is installed
|
||||
- Check for SQL syntax errors in content
|
||||
- Use `/tag list` to confirm tag exists
|
||||
### Saved Responses Not Working
|
||||
- Check MongoDB connection and permissions
|
||||
- Use `/response list` to confirm saved response exists
|
||||
- Check for errors in saved response content
|
||||
|
||||
### Priority Not Updating
|
||||
- Verify ticket exists in database
|
||||
@@ -501,10 +435,9 @@ THREAD_PARENT_CHANNEL=
|
||||
## Performance Considerations
|
||||
|
||||
### Database
|
||||
- SQLite suitable for small-medium deployments
|
||||
- Consider MongoDB migration for high volume
|
||||
- MongoDB (Mongoose) for all persistent data
|
||||
- Regular backups recommended
|
||||
- Vacuum database periodically
|
||||
- Run from repo root; `.env` in repo root
|
||||
|
||||
### Auto-Checks
|
||||
- Auto-close: Runs every hour
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project Structure
|
||||
|
||||
Overview of the **gmail-bridge** project layout and the role of each file and directory.
|
||||
Overview of the **Broccolini Bot** project layout and the role of each file and directory. Single-level repo: all paths are relative to the repo root.
|
||||
|
||||
---
|
||||
|
||||
@@ -8,7 +8,7 @@ Overview of the **gmail-bridge** project layout and the role of each file and di
|
||||
|
||||
| File / Dir | Purpose |
|
||||
|------------|--------|
|
||||
| `zammad-discord.js` | **Entry point.** Main Discord bot + Gmail bridge process. |
|
||||
| `broccolini-discord.js` | **Entry point.** Main Discord bot process. |
|
||||
| `config.js` | Configuration loading (env, defaults). |
|
||||
| `db-connection.js` | MongoDB connection setup. |
|
||||
| `models.js` | Mongoose models (e.g. guild settings, tickets). |
|
||||
@@ -16,8 +16,9 @@ Overview of the **gmail-bridge** project layout and the role of each file and di
|
||||
| `gmail-poll.js` | Gmail polling / inbox sync logic. |
|
||||
| `game-options.json` | Game-related options (e.g. for slash commands). |
|
||||
| `package.json` | Dependencies and npm scripts. |
|
||||
| `.env.example` | Example environment variables. |
|
||||
| `.gitignore` | Git ignore rules. |
|
||||
| `.env.example` | Example environment variables (copy to `.env`). |
|
||||
| `.env.test.example` | Test env template (copy to `.env.test`; run with `npm run start:test`). See [ENV_AND_SECURITY.md](./ENV_AND_SECURITY.md). |
|
||||
| `.gitignore` | Git ignore rules (`.env` and `.env.test` never committed). |
|
||||
|
||||
---
|
||||
|
||||
@@ -57,9 +58,7 @@ Core business logic and external integrations.
|
||||
| `debugLog.js` | Debug / structured logging. |
|
||||
| `gmail.js` | Gmail API integration (read/send, labels). |
|
||||
| `guildSettings.js` | Guild-specific settings (DB + cache). |
|
||||
| `tickets.js` | Ticket lifecycle (create, update, link to Zammad). |
|
||||
| `zammad.js` | Zammad API client (tickets, users, articles). |
|
||||
| `zammad-sync.js` | Sync logic between Discord and Zammad. |
|
||||
| `tickets.js` | Ticket lifecycle (create, update, auto-close, reminders). |
|
||||
|
||||
---
|
||||
|
||||
@@ -79,26 +78,18 @@ One-off or maintenance scripts.
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| `create-zammad-objects.js` | Creates required objects in Zammad (run via `npm run create-zammad-objects`). |
|
||||
| `backup-env.js` | Copies `.env` to `.env.backup` (run via `node scripts/backup-env.js`). |
|
||||
| `test-mongodb.js` | Tests MongoDB connection (run via `npm run test-mongodb`). |
|
||||
|
||||
---
|
||||
|
||||
### `docs/`
|
||||
|
||||
Documentation and reference files.
|
||||
Documentation and reference files (all paths below relative to repo root).
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| `MONGODB_ZAMMAD_LINK.md` | How MongoDB and Zammad are linked. |
|
||||
| `schema zammad.txt` | Zammad schema or object reference. |
|
||||
|
||||
---
|
||||
|
||||
## Documentation (root)
|
||||
|
||||
| File | Purpose |
|
||||
|------|--------|
|
||||
| `README.md` | Main project readme. |
|
||||
| `ENV_AND_SECURITY.md` | Test env workflow, security checklist, agent rules. |
|
||||
| `QUICKSTART.md` | Quick start / setup guide. |
|
||||
| `FEATURES_SUMMARY.md` | Feature overview. |
|
||||
| `IMPLEMENTATION_SUMMARY.md` | Implementation notes. |
|
||||
@@ -108,6 +99,7 @@ Documentation and reference files.
|
||||
| `UPGRADE_COMPLETE.md` | Upgrade completion notes. |
|
||||
| `DISCORD_API_VALIDATION.md` | Discord API validation details. |
|
||||
| `DISCORD_API_IMPROVEMENTS.md` | Discord API improvements. |
|
||||
| `PROPOSAL.md` | Roadmap and possible next steps (API, routing, bOSScord). |
|
||||
| `PROJECT_STRUCTURE.md` | This file. |
|
||||
|
||||
---
|
||||
@@ -115,8 +107,8 @@ Documentation and reference files.
|
||||
## Tree View
|
||||
|
||||
```
|
||||
gmail-bridge/
|
||||
├── zammad-discord.js # Entry point
|
||||
broccolini-bot/
|
||||
├── broccolini-discord.js # Entry point
|
||||
├── config.js
|
||||
├── db-connection.js
|
||||
├── models.js
|
||||
@@ -139,22 +131,24 @@ gmail-bridge/
|
||||
│ ├── debugLog.js
|
||||
│ ├── gmail.js
|
||||
│ ├── guildSettings.js
|
||||
│ ├── tickets.js
|
||||
│ ├── zammad.js
|
||||
│ └── zammad-sync.js
|
||||
│ └── tickets.js
|
||||
├── utils/
|
||||
│ └── ticketComponents.js
|
||||
├── scripts/
|
||||
│ └── create-zammad-objects.js
|
||||
├── docs/
|
||||
│ ├── MONGODB_ZAMMAD_LINK.md
|
||||
│ └── schema zammad.txt
|
||||
└── *.md # Root documentation files
|
||||
│ ├── backup-env.js
|
||||
│ └── test-mongodb.js
|
||||
├── docs/ # All .md docs except README.md
|
||||
│ ├── ENV_AND_SECURITY.md
|
||||
│ ├── QUICKSTART.md
|
||||
│ ├── MONGODB_SETUP.md
|
||||
│ └── ... (see table above)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
- **Start bot:** `npm start` → runs `node zammad-discord.js`
|
||||
- **Create Zammad objects:** `npm run create-zammad-objects` → runs `node scripts/create-zammad-objects.js`
|
||||
- **Start bot:** `npm start` → runs `node broccolini-discord.js`
|
||||
- **Backup .env:** `node scripts/backup-env.js` → copies `.env` to `.env.backup`
|
||||
- **Test MongoDB:** `npm run test-mongodb` → runs `node scripts/test-mongodb.js`
|
||||
72
docs/PROPOSAL.md
Normal file
72
docs/PROPOSAL.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Broccolini Bot – Roadmap & Proposal
|
||||
|
||||
Short proposal and possible next steps for the Broccolini Bot ticketing system. Discord + Gmail + MongoDB remain the core; any extension is additive.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
- **Email → Discord:** Gmail poll creates ticket channels/threads; replies sync back to Gmail.
|
||||
- **Discord-first:** Panels, slash commands, buttons, modals, context menus for full ticket lifecycle (claim, close, escalate, tag, priority, transfer, move, saved responses).
|
||||
- **MongoDB:** Single data store for tickets, transcripts, tags, close requests, guild settings, and (optional) account info.
|
||||
- **Automation:** Auto-close, reminders, auto-unclaim, claim timeout; all configurable via `.env`.
|
||||
- **Security:** HTML escaping in outbound emails; test env workflow; optional healthcheck host binding.
|
||||
|
||||
No external ticketing API (e.g. Zammad) is used; the bot is self-contained.
|
||||
|
||||
---
|
||||
|
||||
## Possible Next Steps
|
||||
|
||||
### 1. Read-only API layer
|
||||
|
||||
- Expose ticket and metadata via a **read-only** HTTP API (e.g. alongside the bot or a small separate service).
|
||||
- Endpoints: list/filter tickets, ticket by ID, “my tickets” by Discord ID, tags, guild settings.
|
||||
- Enables dashboards, mobile tools, or a **Support Cockpit** (bOSScord-style overlay) without changing Discord or bot behavior.
|
||||
- Optional: use something like Directus on top of MongoDB for instant REST/GraphQL and admin UI.
|
||||
|
||||
### 2. Ticket routing & queues
|
||||
|
||||
- Derive a **queue** (or routing bucket) per ticket from game detection (`GAME_LIST`), subject/body keywords, and existing tags.
|
||||
- Store queue on the ticket document; show it in Discord (e.g. in embeds or channel name) and in any future API/UI.
|
||||
- Enables “Network”, “Billing”, “Mod Help”, “Game X” views without changing how staff use Discord.
|
||||
|
||||
### 3. Incident & problem tracking
|
||||
|
||||
- **Incident:** one-off “something broke” ticket.
|
||||
- **Problem:** recurring issue; link multiple incidents, track root cause, workaround, and fix status.
|
||||
- Optional new commands or buttons to “Link to problem” / “Create problem from ticket” and optional API fields.
|
||||
|
||||
### 4. Knowledge base & PM links
|
||||
|
||||
- Internal KB (e.g. Wiki.js): link from problems/tickets to articles; optional `/kb search` in Discord.
|
||||
- PM tool (Plane, Focalboard, Taiga): “Create PM task from ticket” and link ticket ↔ task.
|
||||
- Broccolini Bot stays the source of truth for tickets; KB and PM are linked data.
|
||||
|
||||
### 5. bOSScord / Support Cockpit
|
||||
|
||||
- Web (later desktop) client that **reads** from the API and MongoDB.
|
||||
- Richer views: queues, SLA-style status, “who’s viewing this ticket”, virtual display names, links to KB and PM.
|
||||
- All writes and communication remain in Discord; the client is view/routing only.
|
||||
|
||||
### 6. GitHub / GitLab (optional)
|
||||
|
||||
- From a problem or PM task: create issue/PR with context.
|
||||
- Webhooks to update problem/task when issues close or PRs merge.
|
||||
|
||||
---
|
||||
|
||||
## Principles
|
||||
|
||||
- **Discord + Broccolini Bot are canonical.** New features augment, they don’t replace, the current flow.
|
||||
- **API-first for new UIs.** Any dashboard or cockpit consumes a read-only (or narrowly write) API; no direct DB access from frontends.
|
||||
- **Config and secrets stay in `.env`.** New services get their own env or reuse existing vars where it makes sense.
|
||||
- **MongoDB remains the primary store.** New collections or fields as needed; no second database unless justified.
|
||||
|
||||
---
|
||||
|
||||
## No Deadlines
|
||||
|
||||
This document is a proposal and idea list. Work can proceed in small steps: e.g. add a read-only ticket API, then add queue derivation, then plug in a simple dashboard or bOSScord.
|
||||
|
||||
For a fuller platform vision (bOSScord, queues, incidents/problems, KB, PM), see the parent repo’s bOSScord proposal if present.
|
||||
@@ -1,6 +1,8 @@
|
||||
# Quick Start Guide - New Features
|
||||
# Broccolini Bot – Quick Start Guide
|
||||
|
||||
Get started with the new features in 5 minutes!
|
||||
Get started with Broccolini Bot in 5 minutes! Run all commands from the repo root. Ensure `.env` exists in the repo root (copy from `.env.example`).
|
||||
|
||||
**Test env:** To try changes safely, use `.env.test` (copy from `.env.test.example`) and run `npm run start:test`. See [ENV_AND_SECURITY.md](./ENV_AND_SECURITY.md). **Agents:** do not modify `.env` without explicit user confirmation; prefer changing `.env.test` first.
|
||||
|
||||
## 1. Restart Your Bot
|
||||
|
||||
@@ -9,7 +11,7 @@ npm start
|
||||
```
|
||||
|
||||
The bot will automatically:
|
||||
- Create new database tables (tags, close_requests)
|
||||
- Use MongoDB collections (Tag, CloseRequest, etc.) as needed
|
||||
- Register all new slash commands
|
||||
- Start background jobs (auto-close, auto-unclaim, reminders)
|
||||
|
||||
@@ -72,7 +74,7 @@ ALLOW_CLAIM_OVERWRITE=true
|
||||
USE_THREADS=false
|
||||
```
|
||||
|
||||
**Restart the bot** after changing `.env`; slash commands may need re-registration (e.g. `npm run register` or restart).
|
||||
**Restart the bot** after changing `.env`; slash commands may need re-registration (restart the bot).
|
||||
|
||||
## 6. Use Variables in Tags
|
||||
|
||||
@@ -187,7 +189,7 @@ Verify everything is working:
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Read `PHASE_FEATURES.md` for detailed documentation
|
||||
- Read [PHASE_FEATURES.md](./PHASE_FEATURES.md) for detailed documentation
|
||||
- Check logs for error messages
|
||||
- Test features in a test channel first
|
||||
- Use `/help` in Discord for command reference
|
||||
@@ -19,7 +19,7 @@ Wait up to 1 hour for Discord to fully sync all commands.
|
||||
#### For Staff:
|
||||
```
|
||||
/search query:test status:open
|
||||
/tag list
|
||||
/response list
|
||||
Right-click any message → "Create Ticket From Message"
|
||||
Right-click any user → "View User Tickets"
|
||||
```
|
||||
@@ -39,7 +39,7 @@ Set priority with `/priority` (dropdown: low, normal, medium, high); channel nam
|
||||
### Commands
|
||||
- **Before:** 15 commands
|
||||
- **After:** 13 slash commands + 2 context menu commands = 15 total
|
||||
- `/tag` commands now grouped: `/tag send`, `/tag create`, etc.
|
||||
- Saved responses: `/response send`, `/response create`, etc.; ticket category: `/tag` (dropdown).
|
||||
|
||||
### New Features
|
||||
- ✅ Search command with filters
|
||||
@@ -79,11 +79,8 @@ View bot analytics and performance metrics.
|
||||
- Error rates
|
||||
- Top commands
|
||||
|
||||
### `/tag send|create|edit|delete|list|set`
|
||||
Tag commands now grouped under one parent. Use `/tag set` to assign a ticket category tag (dropdown: Server Down, Billing, Mod Help, etc.); channel name is updated with **priority emoji first, then tag emoji**.
|
||||
|
||||
**Before:** `/tag-create`
|
||||
**After:** `/tag create`
|
||||
### `/response send|create|edit|delete|list` and `/tag`
|
||||
Saved responses: `/response send`, `/response create`, `/response edit`, `/response delete`, `/response list`. Use `/tag` (dropdown) to set ticket category (Server Down, Billing, Mod Help, etc.); the bot posts a categorization message.
|
||||
|
||||
---
|
||||
|
||||
@@ -232,7 +229,7 @@ Better error messages:
|
||||
1. **DISCORD_API_IMPROVEMENTS.md** - Detailed feature documentation
|
||||
2. **UPGRADE_COMPLETE.md** - This file (quick reference)
|
||||
3. **DISCORD_API_VALIDATION.md** - Original validation report
|
||||
4. **zammad-discord.js** - Updated with all features
|
||||
4. **broccolini-discord.js** - Updated with all features
|
||||
|
||||
### Read These:
|
||||
- **QUICKSTART.md** - Getting started guide
|
||||
@@ -245,13 +242,13 @@ Better error messages:
|
||||
|
||||
### Basic Tests
|
||||
- [x] Run `/help` - Should work
|
||||
- [x] Run `/tag list` - Shows tags
|
||||
- [x] Run `/response list` - Shows saved responses
|
||||
- [x] Run `/stats` - Shows analytics
|
||||
- [x] Run `/search query:test` - Searches tickets
|
||||
- [x] Run `/priority` in a ticket channel - Changes priority and renames channel with emoji
|
||||
- [x] Right-click message - Shows context menu
|
||||
- [x] Right-click user - Shows context menu
|
||||
- [x] Try `/tag delete` - Shows confirmation
|
||||
- [x] Try `/response delete` - Shows confirmation
|
||||
|
||||
### Staff Commands
|
||||
- [x] All staff commands only visible to staff
|
||||
@@ -298,7 +295,7 @@ Better error messages:
|
||||
- 12/12 Improvements Complete
|
||||
- 800+ Lines of Code Added
|
||||
- 2 New Context Menu Commands
|
||||
- 5 Tag Subcommands
|
||||
- 5 /response subcommands (send, create, edit, delete, list)
|
||||
- Full Analytics System
|
||||
- Comprehensive Error Tracking
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ const {
|
||||
EmbedBuilder
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('./db-connection');
|
||||
const { CONFIG, ZAMMAD, GAME_NAME_TO_KEY } = require('./config');
|
||||
const { CONFIG, GAME_NAME_TO_KEY } = require('./config');
|
||||
const {
|
||||
getCleanBody,
|
||||
extractRawEmail,
|
||||
@@ -19,14 +19,12 @@ const {
|
||||
getFormattedDate
|
||||
} = require('./utils');
|
||||
const { getGmailClient } = require('./services/gmail');
|
||||
const { createZammadTicket, updateZammadUserDiscordId } = require('./services/zammad');
|
||||
const { getNextTicketNumber, saveZammadId, checkTicketLimits, pickTicketCategoryId, createEmailTicketAsThread } = require('./services/tickets');
|
||||
const { getNextTicketNumber, checkTicketLimits, pickTicketCategoryId, createEmailTicketAsThread } = require('./services/tickets');
|
||||
const { getEmailRouting } = require('./services/guildSettings');
|
||||
const { logError } = require('./services/debugLog');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const Transcript = mongoose.model('Transcript');
|
||||
const User = mongoose.model('User');
|
||||
|
||||
/**
|
||||
* Poll Gmail for unread primary-inbox messages and route them to Discord.
|
||||
@@ -216,66 +214,27 @@ async function poll(client) {
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
);
|
||||
|
||||
const embed = new EmbedBuilder().setColor(CONFIG.EMBED_COLOR_OPEN).addFields({
|
||||
name: 'Ticket Info',
|
||||
value:
|
||||
`**Name:** ${sName}\n` +
|
||||
`**Email:** ${sEmail}\n` +
|
||||
`**Date:** ${getFormattedDate()}\n` +
|
||||
`**Game:** ${detectedGame}\n` +
|
||||
`**Subject:** ${subject || 'No subject'}`
|
||||
});
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
const ticketInfoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.addFields({
|
||||
name: 'Ticket Info',
|
||||
value:
|
||||
`**Name:** ${sName}\n` +
|
||||
`**Email:** ${sEmail}\n` +
|
||||
`**Date:** ${getFormattedDate()}\n` +
|
||||
`**Game:** ${detectedGame}\n` +
|
||||
`**Subject:** ${subject || 'No subject'}`
|
||||
});
|
||||
|
||||
await ticketChan.send({
|
||||
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
|
||||
embeds: [embed],
|
||||
embeds: [welcomeEmbed, ticketInfoEmbed],
|
||||
components: [buttons]
|
||||
});
|
||||
|
||||
// Look up website User by email for discordID
|
||||
let discordIdFromUser = null;
|
||||
try {
|
||||
const websiteUser = await User.findOne({ email: sEmail.toLowerCase() }).select('discordID').lean();
|
||||
if (websiteUser?.discordID) discordIdFromUser = websiteUser.discordID;
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
// Create Zammad ticket
|
||||
try {
|
||||
const zammadTicket = await createZammadTicket({
|
||||
subject,
|
||||
body: firstBody,
|
||||
email: sEmail,
|
||||
name: sName,
|
||||
gameName: detectedGame,
|
||||
gameKey: gameKey,
|
||||
group: ZAMMAD.EMAIL_GROUP
|
||||
});
|
||||
|
||||
if (zammadTicket?.id) {
|
||||
await saveZammadId(email.data.threadId, zammadTicket.id);
|
||||
}
|
||||
|
||||
if (zammadTicket?.customer_id && discordIdFromUser) {
|
||||
try {
|
||||
await updateZammadUserDiscordId(zammadTicket.customer_id, discordIdFromUser);
|
||||
} catch (zErr) {
|
||||
console.error('Zammad user discord_id update failed (add discord_id on User in Object Manager?):', zErr.response?.data || zErr.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('--- ZAMMAD API ERROR DETAILS ---');
|
||||
if (e.response) {
|
||||
console.error(`Status: ${e.response.status}`);
|
||||
console.error('Response Data:', JSON.stringify(e.response.data, null, 2));
|
||||
console.error('Headers:', e.response.headers);
|
||||
} else if (e.request) {
|
||||
console.error('No response received. Request details:', e.request);
|
||||
} else {
|
||||
console.error('Error setting up request:', e.message);
|
||||
}
|
||||
console.error('--------------------------------');
|
||||
}
|
||||
|
||||
// On reopen, link previous transcripts
|
||||
if (isReopened) {
|
||||
try {
|
||||
|
||||
@@ -15,11 +15,9 @@ const {
|
||||
TextInputStyle
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG, ZAMMAD } = require('../config');
|
||||
const { CONFIG } = require('../config');
|
||||
const { canRename, makeTicketName, minutesFromMs, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { sendTicketClosedEmail } = require('../services/gmail');
|
||||
const { createZammadTicket, closeZammadTicket, ensureZammadUserForDiscordUser, updateZammadUser } = require('../services/zammad');
|
||||
const { saveZammadId } = require('../services/tickets');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { setEmailRouting } = require('../services/guildSettings');
|
||||
const { runEscalation, runDeescalation } = require('./commands');
|
||||
@@ -218,9 +216,9 @@ async function handleButton(interaction) {
|
||||
}
|
||||
|
||||
// --- TAG DELETE CONFIRM ---
|
||||
if (interaction.customId.startsWith('confirm_delete_tag_')) {
|
||||
if (interaction.customId.startsWith('confirm_delete_tag::')) {
|
||||
trackInteraction('buttons', 'confirm-delete-tag', interaction.user.tag);
|
||||
const tagName = interaction.customId.replace('confirm_delete_tag_', '');
|
||||
const tagName = interaction.customId.slice('confirm_delete_tag::'.length);
|
||||
|
||||
try {
|
||||
const result = await Tag.deleteOne({ name: tagName });
|
||||
@@ -331,9 +329,12 @@ async function handleClaim(interaction, ticket) {
|
||||
.setLabel(label);
|
||||
|
||||
await interaction.update({ components: [row] });
|
||||
const claimText = CONFIG.TICKET_CLAIMED_MESSAGE
|
||||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||||
const claimEmbed = new EmbedBuilder()
|
||||
.setDescription(`Ticket claimed by ${interaction.user.toString()}`)
|
||||
.setColor(0x2ecc71);
|
||||
.setDescription(claimText)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
await interaction.followUp({ embeds: [claimEmbed] });
|
||||
} else {
|
||||
// Unclaim
|
||||
@@ -378,9 +379,12 @@ async function handleClaim(interaction, ticket) {
|
||||
.setLabel(CONFIG.BUTTON_LABEL_CLAIM);
|
||||
|
||||
await interaction.update({ components: [row] });
|
||||
const unclaimText = CONFIG.TICKET_UNCLAIMED_MESSAGE
|
||||
.replace(/\{staff_mention\}/g, interaction.user.toString())
|
||||
.replace(/\{staff_name\}/g, interaction.member?.displayName || interaction.user.username);
|
||||
const unclaimEmbed = new EmbedBuilder()
|
||||
.setDescription(`Ticket unclaimed by ${interaction.user.toString()}`)
|
||||
.setColor(0xf1c40f);
|
||||
.setDescription(unclaimText)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
await interaction.followUp({ embeds: [unclaimEmbed] });
|
||||
}
|
||||
}
|
||||
@@ -410,40 +414,48 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
name: `transcript-${interaction.channel.name}.txt`
|
||||
});
|
||||
|
||||
const channelName = interaction.channel.name;
|
||||
const opened = new Date(ticket.createdAt);
|
||||
const openedStr = opened.toLocaleString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
const closedStr = closedAt.toLocaleString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
// In-ticket message before transcript is posted (Discord close message)
|
||||
const discordCloseContent = CONFIG.DISCORD_CLOSE_MESSAGE;
|
||||
await interaction.channel.send(discordCloseContent);
|
||||
|
||||
const transcriptChan = await interaction.client.channels
|
||||
.fetch(CONFIG.TRANSCRIPT_CHAN)
|
||||
.catch(() => null);
|
||||
let transcriptMsg = null;
|
||||
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelName)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
|
||||
if (transcriptChan) {
|
||||
const opened = new Date(ticket.createdAt);
|
||||
const openedStr = opened.toLocaleString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
const closedStr = closedAt.toLocaleString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: true,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
transcriptMsg = await transcriptChan.send({
|
||||
content:
|
||||
`Transcript: \`${ticket.senderEmail}\`\n` +
|
||||
`Date Opened: ${openedStr}\n` +
|
||||
`Date Closed: ${closedStr}`,
|
||||
content: transcriptContent,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
@@ -454,10 +466,15 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
try {
|
||||
const creator = await interaction.client.users.fetch(creatorId);
|
||||
const dmFile = new AttachmentBuilder(Buffer.from(log), {
|
||||
name: `transcript-${interaction.channel.name}.txt`
|
||||
name: `transcript-${channelName}.txt`
|
||||
});
|
||||
const dmContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, channelName)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr);
|
||||
await creator.send({
|
||||
content: `Your ticket **${interaction.channel.name}** has been closed. Here is your transcript:`,
|
||||
content: dmContent,
|
||||
files: [dmFile]
|
||||
});
|
||||
} catch (dmErr) {
|
||||
@@ -471,7 +488,6 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
if (logChan) {
|
||||
const closerMention = interaction.user.toString();
|
||||
const closerDisplayName = interaction.member?.displayName || interaction.user.username;
|
||||
const channelName = interaction.channel.name;
|
||||
|
||||
let logMsg;
|
||||
if (ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
@@ -495,12 +511,6 @@ async function handleConfirmClose(interaction, ticket) {
|
||||
if (!ticket.gmailThreadId?.startsWith('discord-')) {
|
||||
await sendTicketClosedEmail(ticket, closerDisplayName);
|
||||
}
|
||||
if (ticket.zammadTicketId && ZAMMAD?.URL && ZAMMAD?.TOKEN) {
|
||||
await closeZammadTicket(ticket.zammadTicketId).catch(zErr =>
|
||||
console.error('Zammad close failed:', zErr.message)
|
||||
);
|
||||
}
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { discordThreadId: null, status: 'closed' } }
|
||||
@@ -599,53 +609,12 @@ async function handleTicketModal(interaction) {
|
||||
lastActivity: now
|
||||
});
|
||||
|
||||
// Create Zammad ticket for Discord-originated ticket
|
||||
const displayName = interaction.member?.displayName || interaction.user.username;
|
||||
try {
|
||||
const zammadTicket = await createZammadTicket({
|
||||
subject,
|
||||
body: description,
|
||||
email,
|
||||
name: displayName,
|
||||
gameName: game || 'Not specified',
|
||||
gameKey: null,
|
||||
group: ZAMMAD.DISCORD_GROUP,
|
||||
discordUsername: displayName
|
||||
});
|
||||
if (zammadTicket?.id) {
|
||||
await saveZammadId(gmailThreadId, zammadTicket.id);
|
||||
}
|
||||
// Update Zammad customer with Discord username and ID so they show in user/ticket views
|
||||
if (zammadTicket?.customer_id) {
|
||||
try {
|
||||
await updateZammadUser(zammadTicket.customer_id, {
|
||||
discord_username: displayName,
|
||||
discord_id: interaction.user.id
|
||||
});
|
||||
} catch (_) {
|
||||
/* custom attributes may not exist in Zammad */
|
||||
}
|
||||
}
|
||||
} catch (zErr) {
|
||||
console.error('Zammad ticket create (Discord ticket) failed:', zErr.response?.data || zErr.message);
|
||||
}
|
||||
|
||||
// Ensure Zammad user if creator has a website account (keeps discord_id/discord_username in sync)
|
||||
try {
|
||||
const websiteUser = await User.findOne({ discordID: String(interaction.user.id) })
|
||||
.select('email discordID firstname lastname')
|
||||
.lean();
|
||||
if (websiteUser?.email) {
|
||||
await ensureZammadUserForDiscordUser(websiteUser, { discordUsername: displayName });
|
||||
}
|
||||
} catch (zErr) {
|
||||
console.error('Zammad user ensure (Discord ticket) failed:', zErr.message);
|
||||
}
|
||||
|
||||
// Welcome embed (green)
|
||||
// Welcome embed (dark grey #1e2124)
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setDescription("We got your ticket. We'll be with you as soon as possible.\nFeel free to add any additional information to your ticket.")
|
||||
.setColor(0x2ecc71)
|
||||
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
.setFooter({ text: 'Indifferent Broccoli Tickets' });
|
||||
|
||||
// Ticket details embed (dark) – short labels, trimmed description
|
||||
|
||||
@@ -11,10 +11,9 @@ const {
|
||||
PermissionFlagsBits
|
||||
} = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG, ZAMMAD, TICKET_TAGS } = require('../config');
|
||||
const { CONFIG, TICKET_TAGS } = require('../config');
|
||||
const { getPriorityEmoji, replaceVariables, escapeRegex } = require('../utils');
|
||||
const { canRename, makeTicketName, pickTicketCategoryId, createDiscordTicketAsThread, checkTicketCreationRateLimit } = require('../services/tickets');
|
||||
const { closeZammadTicket, ensureZammadUserForDiscordUser } = require('../services/zammad');
|
||||
const { sendTicketNotificationEmail } = require('../services/gmail');
|
||||
const { getTicketActionRow } = require('../utils/ticketComponents');
|
||||
const { getEmailRouting } = require('../services/guildSettings');
|
||||
@@ -26,6 +25,36 @@ const Ticket = mongoose.model('Ticket');
|
||||
const Tag = mongoose.model('Tag');
|
||||
const User = mongoose.model('User');
|
||||
|
||||
/**
|
||||
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
||||
* Used to restrict commands to staff only; customers cannot use bot commands.
|
||||
* @param {import('discord.js').GuildMember|null} member
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasStaffRole(member) {
|
||||
if (!member?.roles?.cache) return false;
|
||||
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
|
||||
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
|
||||
return additional.some(roleId => member.roles.cache.has(roleId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply ephemeral and return true if the interaction is in a guild and the user is not staff (so caller should return).
|
||||
* @param {import('discord.js').CommandInteraction|import('discord.js').ContextMenuCommandInteraction} interaction
|
||||
* @returns {Promise<boolean>} true if caller should return (user is not allowed)
|
||||
*/
|
||||
async function requireStaffRole(interaction) {
|
||||
if (!interaction.guild) return false;
|
||||
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
|
||||
if (hasStaffRole(interaction.member)) return false;
|
||||
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
|
||||
await interaction.reply({
|
||||
content: `This command is only available to the support team (${roleMention}).`,
|
||||
ephemeral: true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run escalation to a target DB tier (1 = tier 2, 2 = tier 3). Caller must validate ticket and currentTier < nextTier.
|
||||
*/
|
||||
@@ -69,7 +98,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
|
||||
const pendingEmbed = new EmbedBuilder()
|
||||
.setDescription('Ticket will be escalated in a few seconds.')
|
||||
.setColor(0xe74c3c);
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
await interaction.reply({ content: null, embeds: [pendingEmbed] });
|
||||
|
||||
const creatorId = isDiscordTicket
|
||||
@@ -84,12 +113,12 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
`${heyLine}\n**Getting the senior ${roleMention} for you.**`
|
||||
);
|
||||
|
||||
const seniorLine = `A senior ${CONFIG.SUPPORT_NAME} will be here to assist as soon as possible.`;
|
||||
const escalationBody = CONFIG.ESCALATION_MESSAGE
|
||||
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME)
|
||||
+ (reason ? `\n\n**Reason:** ${reason}` : '');
|
||||
const escalatedEmbed = new EmbedBuilder()
|
||||
.setDescription(
|
||||
`${CONFIG.ESCALATION_MESSAGE}\n\n${seniorLine}${reason ? `\n\n**Reason:** ${reason}` : ''}`
|
||||
)
|
||||
.setColor(0x2ecc71);
|
||||
.setDescription(escalationBody)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
|
||||
const escalationRow = getTicketActionRow(updatedTicketForRow);
|
||||
await interaction.channel.send({
|
||||
@@ -99,7 +128,7 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
|
||||
});
|
||||
|
||||
if (!isDiscordTicket && ticket.gmailThreadId) {
|
||||
const emailBody = `${CONFIG.ESCALATION_MESSAGE}\n\n${seniorLine}${reason ? `\n\nReason: ${reason}` : ''}`;
|
||||
const emailBody = CONFIG.ESCALATION_MESSAGE.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME) + (reason ? `\n\nReason: ${reason}` : '');
|
||||
await sendTicketNotificationEmail(
|
||||
ticket,
|
||||
`Ticket escalated to ${nextTier === 1 ? 'tier 2' : 'tier 3'}`,
|
||||
@@ -199,6 +228,9 @@ async function runDeescalation(interaction, ticket) {
|
||||
* Main slash-command handler.
|
||||
*/
|
||||
async function handleCommand(interaction) {
|
||||
// Only /help can be used by everyone; all other commands require staff role (ROLE_ID_TO_PING / ADDITIONAL_STAFF_ROLES)
|
||||
if (interaction.commandName !== 'help' && (await requireStaffRole(interaction))) return;
|
||||
|
||||
// /setup
|
||||
if (interaction.commandName === 'setup') {
|
||||
return handleSetupCommand(interaction);
|
||||
@@ -415,8 +447,9 @@ async function handleCommand(interaction) {
|
||||
|
||||
await interaction.reply('Ticket force-closed. Archiving...');
|
||||
|
||||
// Generate transcript inline (same as confirm_close)
|
||||
try {
|
||||
await interaction.channel.send(CONFIG.DISCORD_CLOSE_MESSAGE);
|
||||
|
||||
const messages = await interaction.channel.messages.fetch({ limit: 100 });
|
||||
const log =
|
||||
`TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
|
||||
@@ -434,8 +467,25 @@ async function handleCommand(interaction) {
|
||||
.catch(() => null);
|
||||
|
||||
if (transcriptChan) {
|
||||
const closedAt = new Date();
|
||||
const openedStr = new Date(ticket.createdAt).toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const closedStr = closedAt.toLocaleString('en-US', {
|
||||
month: '2-digit', day: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: true, timeZoneName: 'short'
|
||||
});
|
||||
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
|
||||
.replace(/\{channel_name\}/g, interaction.channel.name)
|
||||
.replace(/\{email\}/g, ticket.senderEmail || '')
|
||||
.replace(/\{date_opened\}/g, openedStr)
|
||||
.replace(/\{date_closed\}/g, closedStr)
|
||||
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
|
||||
await transcriptChan.send({
|
||||
content: `Force-closed transcript: \`${ticket.senderEmail}\``,
|
||||
content: transcriptContent,
|
||||
files: [file]
|
||||
});
|
||||
}
|
||||
@@ -443,10 +493,6 @@ async function handleCommand(interaction) {
|
||||
console.error('Transcript error (force-close):', tErr);
|
||||
}
|
||||
|
||||
if (ticket.zammadTicketId && ZAMMAD.URL && ZAMMAD.TOKEN) {
|
||||
await closeZammadTicket(ticket.zammadTicketId);
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await interaction.channel.delete('Ticket force-closed');
|
||||
@@ -567,9 +613,11 @@ async function handleCommand(interaction) {
|
||||
|
||||
else if (subcommand === 'delete') {
|
||||
const name = interaction.options.getString('name');
|
||||
// Use :: delimiter so tag names with underscores are parsed correctly (Discord customId max 100 chars)
|
||||
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
|
||||
const confirmRow = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`confirm_delete_tag_${name}`)
|
||||
.setCustomId(customId)
|
||||
.setLabel('Yes, Delete Tag')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
new ButtonBuilder()
|
||||
@@ -726,12 +774,12 @@ async function handleCommand(interaction) {
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket (thread)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵'),
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket (channel)')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
} else if (panelType === 'thread') {
|
||||
@@ -739,7 +787,7 @@ async function handleCommand(interaction) {
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_thread')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('🧵')
|
||||
);
|
||||
} else if (panelType === 'category') {
|
||||
@@ -747,7 +795,7 @@ async function handleCommand(interaction) {
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket_channel')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('📁')
|
||||
);
|
||||
} else {
|
||||
@@ -755,7 +803,7 @@ async function handleCommand(interaction) {
|
||||
new ButtonBuilder()
|
||||
.setCustomId('open_ticket')
|
||||
.setLabel('Create ticket')
|
||||
.setStyle(ButtonStyle.Success)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setEmoji('✅')
|
||||
);
|
||||
}
|
||||
@@ -925,6 +973,9 @@ async function handleCommand(interaction) {
|
||||
* Context menu interaction handler.
|
||||
*/
|
||||
async function handleContextMenu(interaction) {
|
||||
// Restrict all guild context menus to staff role only
|
||||
if (await requireStaffRole(interaction)) return;
|
||||
|
||||
// Create Ticket From Message
|
||||
if (interaction.isMessageContextMenuCommand() && interaction.commandName === 'Create Ticket From Message') {
|
||||
trackInteraction('contextMenus', 'create-ticket-from-message', interaction.user.tag);
|
||||
@@ -991,20 +1042,9 @@ async function handleContextMenu(interaction) {
|
||||
lastActivity: now
|
||||
});
|
||||
|
||||
try {
|
||||
const websiteUser = await User.findOne({ discordID: String(message.author.id) })
|
||||
.select('email discordID firstname lastname')
|
||||
.lean();
|
||||
if (websiteUser?.email) {
|
||||
await ensureZammadUserForDiscordUser(websiteUser);
|
||||
}
|
||||
} catch (zErr) {
|
||||
console.error('Zammad user ensure (Discord ticket from message) failed:', zErr.message);
|
||||
}
|
||||
|
||||
const welcomeEmbed = new EmbedBuilder()
|
||||
.setDescription("We got your ticket. We'll be with you as soon as possible.\nFeel free to add any additional information to your ticket.")
|
||||
.setColor(0x2ecc71);
|
||||
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO);
|
||||
|
||||
const infoEmbed = new EmbedBuilder()
|
||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* Discord messageCreate handler – forwards staff replies to Gmail and Zammad.
|
||||
* Discord messageCreate handler – forwards staff replies to Gmail.
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { ZAMMAD } = require('../config');
|
||||
const { extractRawEmail } = require('../utils');
|
||||
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
||||
const { addZammadArticle } = require('../services/zammad');
|
||||
const { updateTicketActivity } = require('../services/tickets');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
/**
|
||||
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only) + Zammad.
|
||||
* Handle a Discord message in a ticket channel → relay to Gmail (email tickets only).
|
||||
*/
|
||||
async function handleDiscordReply(m) {
|
||||
if (m.author.bot || m.interaction) return;
|
||||
@@ -22,19 +20,11 @@ async function handleDiscordReply(m) {
|
||||
|
||||
const discordUser = m.member?.displayName || m.author.username;
|
||||
|
||||
// Discord-originated tickets: no Gmail thread; only add reply to Zammad.
|
||||
if (ticket.gmailThreadId.startsWith('discord-')) {
|
||||
if (ticket.zammadTicketId && ZAMMAD.URL && ZAMMAD.TOKEN) {
|
||||
try {
|
||||
await addZammadArticle(ticket.zammadTicketId, m.content, { from: discordUser });
|
||||
} catch (zErr) {
|
||||
console.error('Zammad article (Discord ticket reply) failed:', zErr.response?.data || zErr.message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Email tickets: send reply via Gmail and add to Zammad.
|
||||
// Email tickets: send reply via Gmail.
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const thread = await gmail.users.threads.get({
|
||||
@@ -78,14 +68,6 @@ async function handleDiscordReply(m) {
|
||||
);
|
||||
|
||||
await updateTicketActivity(ticket.gmailThreadId);
|
||||
|
||||
if (ticket.zammadTicketId && ZAMMAD.URL && ZAMMAD.TOKEN) {
|
||||
try {
|
||||
await addZammadArticle(ticket.zammadTicketId, m.content, { from: discordUser });
|
||||
} catch (zErr) {
|
||||
console.error('Zammad article (Discord reply) failed:', zErr.response?.data || zErr.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('REPLY ERROR:', e);
|
||||
}
|
||||
|
||||
@@ -790,13 +790,13 @@ mongoose.model('ErrorLog', new mongoose.Schema({
|
||||
sessionValid: Boolean
|
||||
}));
|
||||
|
||||
// ===== Gmail-Discord-Zammad Bridge Models =====
|
||||
// ===== Broccolini Bot Models =====
|
||||
|
||||
mongoose.model('Ticket', new mongoose.Schema({
|
||||
gmailThreadId: { type: String, required: true, unique: true, index: true },
|
||||
discordThreadId: String,
|
||||
zammadTicketId: Number,
|
||||
lastSyncedZammadArticleId: Number, // last agent reply we pushed to Discord/Gmail
|
||||
broccoliniTicketId: Number,
|
||||
lastSyncedBroccoliniArticleId: Number, // last agent reply we pushed to Discord/Gmail
|
||||
senderEmail: { type: String, required: true },
|
||||
subject: String,
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
|
||||
330
package-lock.json
generated
330
package-lock.json
generated
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "gmail-bridge",
|
||||
"name": "broccolini-bot",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gmail-bridge",
|
||||
"name": "broccolini-bot",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.4",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^11.0.6",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^171.4.0",
|
||||
"mongodb": "^7.1.0",
|
||||
"mongoose": "^6.12.0"
|
||||
}
|
||||
},
|
||||
@@ -1276,7 +1276,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz",
|
||||
"integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"sparse-bitfield": "^3.0.3"
|
||||
}
|
||||
@@ -1960,12 +1959,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-url": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz",
|
||||
"integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz",
|
||||
"integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
@@ -2034,23 +2032,6 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -2224,18 +2205,6 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||
@@ -2316,15 +2285,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -2489,21 +2449,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -2637,26 +2582,6 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
@@ -2673,43 +2598,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -2764,20 +2652,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -2854,6 +2728,20 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library/node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
@@ -2929,21 +2817,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -3170,8 +3043,7 @@
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
||||
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
@@ -3235,31 +3107,71 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb": {
|
||||
"version": "4.17.2",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz",
|
||||
"integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz",
|
||||
"integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bson": "^4.7.2",
|
||||
"mongodb-connection-string-url": "^2.6.0",
|
||||
"socks": "^2.7.1"
|
||||
"@mongodb-js/saslprep": "^1.3.0",
|
||||
"bson": "^7.1.1",
|
||||
"mongodb-connection-string-url": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.9.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.186.0",
|
||||
"@mongodb-js/saslprep": "^1.1.0"
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.806.0",
|
||||
"@mongodb-js/zstd": "^7.0.0",
|
||||
"gcp-metadata": "^7.0.1",
|
||||
"kerberos": "^7.0.0",
|
||||
"mongodb-client-encryption": ">=7.0.0 <7.1.0",
|
||||
"snappy": "^7.3.2",
|
||||
"socks": "^2.8.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@aws-sdk/credential-providers": {
|
||||
"optional": true
|
||||
},
|
||||
"@mongodb-js/zstd": {
|
||||
"optional": true
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"optional": true
|
||||
},
|
||||
"kerberos": {
|
||||
"optional": true
|
||||
},
|
||||
"mongodb-client-encryption": {
|
||||
"optional": true
|
||||
},
|
||||
"snappy": {
|
||||
"optional": true
|
||||
},
|
||||
"socks": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb-connection-string-url": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz",
|
||||
"integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz",
|
||||
"integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/whatwg-url": "^8.2.1",
|
||||
"whatwg-url": "^11.0.0"
|
||||
"@types/whatwg-url": "^13.0.0",
|
||||
"whatwg-url": "^14.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongodb/node_modules/bson": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz",
|
||||
"integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose": {
|
||||
@@ -3284,6 +3196,69 @@
|
||||
"url": "https://opencollective.com/mongoose"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/@types/whatwg-url": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz",
|
||||
"integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/webidl-conversions": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/mongodb": {
|
||||
"version": "4.17.2",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz",
|
||||
"integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bson": "^4.7.2",
|
||||
"mongodb-connection-string-url": "^2.6.0",
|
||||
"socks": "^2.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@aws-sdk/credential-providers": "^3.186.0",
|
||||
"@mongodb-js/saslprep": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/mongodb-connection-string-url": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz",
|
||||
"integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/whatwg-url": "^8.2.1",
|
||||
"whatwg-url": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/tr46": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
|
||||
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/whatwg-url": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
|
||||
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^3.0.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mpath": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
|
||||
@@ -3454,12 +3429,6 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -3756,7 +3725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
||||
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"memory-pager": "^1.0.2"
|
||||
}
|
||||
@@ -3889,15 +3857,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz",
|
||||
"integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.1.1"
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-mixer": {
|
||||
@@ -3984,16 +3952,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz",
|
||||
"integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==",
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^3.0.0",
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
|
||||
18
package.json
18
package.json
@@ -1,23 +1,25 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"axios": "^1.13.4",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^11.0.6",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^171.4.0",
|
||||
"mongoose": "^6.12.0",
|
||||
"dotenv-expand": "^11.0.6"
|
||||
"mongodb": "^7.1.0",
|
||||
"mongoose": "^6.12.0"
|
||||
},
|
||||
"name": "gmail-bridge",
|
||||
"name": "broccolini-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "zammad-discord.js",
|
||||
"main": "broccolini-discord.js",
|
||||
"scripts": {
|
||||
"start": "node zammad-discord.js",
|
||||
"create-zammad-objects": "node scripts/create-zammad-objects.js"
|
||||
"start": "node broccolini-discord.js",
|
||||
"start:test": "ENV_FILE=.env.test node broccolini-discord.js",
|
||||
"test-mongodb": "node scripts/test-mongodb.js",
|
||||
"test-mongodb:test": "ENV_FILE=.env.test node scripts/test-mongodb.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs"
|
||||
}
|
||||
}
|
||||
|
||||
20
scripts/backup-env.js
Normal file
20
scripts/backup-env.js
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Copy .env to .env.backup. Run whenever you want to save a snapshot.
|
||||
* .env.backup is in .gitignore and is never committed.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const src = path.join(root, '.env');
|
||||
const dest = path.join(root, '.env.backup');
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
console.error('No .env file found. Nothing to backup.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log('Backup written to .env.backup');
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Create Zammad objects to match the bridge schema (groups, etc.).
|
||||
* Uses same .env as the bridge (ZAMMAD_URL, ZAMMAD_TOKEN).
|
||||
*
|
||||
* Run from gmail-bridge: node scripts/create-zammad-objects.js
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const { ZAMMAD } = require('../config');
|
||||
|
||||
const baseURL = ZAMMAD.URL?.replace(/\/+$/, '') || '';
|
||||
const headers = {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
async function apiGet(path) {
|
||||
const { data } = await axios.get(`${baseURL}${path}`, { headers });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const { data } = await axios.post(`${baseURL}${path}`, body, { headers });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!baseURL || !ZAMMAD.TOKEN) {
|
||||
console.error('Set ZAMMAD_URL and ZAMMAD_TOKEN in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Zammad base URL:', baseURL);
|
||||
console.log('');
|
||||
|
||||
// --- Groups (bridge uses ZAMMAD_EMAIL_GROUP and ZAMMAD_DISCORD_GROUP) ---
|
||||
const groupNames = [
|
||||
ZAMMAD.EMAIL_GROUP || 'Email Users',
|
||||
ZAMMAD.DISCORD_GROUP || 'Discord Users'
|
||||
];
|
||||
|
||||
let groups;
|
||||
try {
|
||||
groups = await apiGet('/api/v1/groups');
|
||||
} catch (e) {
|
||||
console.error('GET /api/v1/groups failed:', e.response?.status, e.response?.data || e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existingNames = (groups || []).map((g) => g.name);
|
||||
for (const name of groupNames) {
|
||||
if (existingNames.includes(name)) {
|
||||
console.log(`Group "${name}" already exists.`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await apiPost('/api/v1/groups', { name, active: true });
|
||||
console.log(`Created group: "${name}"`);
|
||||
} catch (e) {
|
||||
console.error(`Create group "${name}" failed:`, e.response?.status, e.response?.data || e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// --- Ticket priorities (for bridge priority sync: low / normal / high) ---
|
||||
try {
|
||||
const priorities = await apiGet('/api/v1/ticket_priorities');
|
||||
console.log('Ticket priorities (for /priority and buttons):');
|
||||
(priorities || []).forEach((p) => {
|
||||
console.log(` id=${p.id} name="${p.name}" default_create=${p.default_create}`);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('GET /api/v1/ticket_priorities failed:', e.response?.status, e.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
// --- Ticket states (bridge uses state "new" on create, "closed" on force-close) ---
|
||||
try {
|
||||
const states = await apiGet('/api/v1/ticket_states');
|
||||
console.log('Ticket states (bridge uses "new" and "closed"):');
|
||||
(states || []).forEach((s) => {
|
||||
console.log(` id=${s.id} name="${s.name}" default_create=${s.default_create}`);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('GET /api/v1/ticket_states failed:', e.response?.status, e.message);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Done. Bridge expects group names in .env: ZAMMAD_EMAIL_GROUP, ZAMMAD_DISCORD_GROUP.');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
45
scripts/test-mongodb.js
Normal file
45
scripts/test-mongodb.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Test MongoDB connection using the native driver.
|
||||
* Uses MONGODB_URI from .env (or ENV_FILE when set). Run: npm run test-mongodb
|
||||
*/
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const envPath = process.env.ENV_FILE ? path.resolve(process.cwd(), process.env.ENV_FILE) : undefined;
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
const { MongoClient, ServerApiVersion } = require('mongodb');
|
||||
|
||||
const uri = (process.env.MONGODB_URI || '').trim();
|
||||
const scheme = uri.split('://')[0];
|
||||
|
||||
if (!uri) {
|
||||
console.error('MONGODB_URI is not set in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
if (scheme !== 'mongodb' && scheme !== 'mongodb+srv') {
|
||||
console.error('MONGODB_URI must start with mongodb:// or mongodb+srv://');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new MongoClient(uri, {
|
||||
serverApi: {
|
||||
version: ServerApiVersion.v1,
|
||||
strict: true,
|
||||
deprecationErrors: true,
|
||||
},
|
||||
});
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
await client.connect();
|
||||
await client.db('admin').command({ ping: 1 });
|
||||
console.log('Pinged your deployment. You successfully connected to MongoDB!');
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail } = require('../utils');
|
||||
const { extractRawEmail, escapeHtml } = require('../utils');
|
||||
|
||||
function getGmailClient() {
|
||||
const auth = new google.auth.OAuth2(
|
||||
@@ -27,19 +27,23 @@ async function sendGmailReply(
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
`Re: ${subject}`
|
||||
).toString('base64')}?=`;
|
||||
const safeUser = escapeHtml(discordUser);
|
||||
const safeReply = escapeHtml(replyText).replace(/\n/g, '<br>');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${discordUser} on Discord</p>
|
||||
<p>${replyText.replace(/\n/g, '<br>')}</p>
|
||||
<p><strong>From:</strong> ${safeUser} on Discord</p>
|
||||
<p>${safeReply}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
<img src="${CONFIG.LOGO_URL}" width="65">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${discordUser}</p>
|
||||
<div style="color: #666; font-size: 12px;">${CONFIG.SIGNATURE}</div>
|
||||
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -100,23 +104,27 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
finalSubject
|
||||
).toString('base64')}?=`;
|
||||
|
||||
const serverDisplayName = discordDisplayName || 'Support';
|
||||
const serverDisplayName = escapeHtml(discordDisplayName || 'Support');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '');
|
||||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '');
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${CONFIG.TICKET_CLOSE_MESSAGE.replace(/\n/g, '<br>')}</p>
|
||||
<p style="margin-top: 16px;">${CONFIG.TICKET_CLOSE_SIGNATURE}</p>
|
||||
<p>${safeCloseMessage}</p>
|
||||
<p style="margin-top: 16px;">${safeCloseSignature}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
<img src="${CONFIG.LOGO_URL}" width="65">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
|
||||
<div style="color: #666; font-size: 12px;">${CONFIG.SIGNATURE}</div>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -180,20 +188,23 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
||||
|
||||
const finalSubject = subjectLine || subjectHeader;
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
|
||||
const label = fromLabel || CONFIG.SUPPORT_NAME || 'Support';
|
||||
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
|
||||
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
|
||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '');
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${label} on Discord</p>
|
||||
<p>${(messageBody || '').replace(/\n/g, '<br>')}</p>
|
||||
<p>${safeBody}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
<img src="${CONFIG.LOGO_URL}" width="65">
|
||||
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${label}</p>
|
||||
<div style="color: #666; font-size: 12px;">${CONFIG.SIGNATURE}</div>
|
||||
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -22,13 +22,6 @@ async function getNextTicketNumber(senderEmail) {
|
||||
return { local: senderLocal, number: counter.counter };
|
||||
}
|
||||
|
||||
async function saveZammadId(gmailThreadId, zammadId) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId },
|
||||
{ $set: { zammadTicketId: zammadId } }
|
||||
);
|
||||
}
|
||||
|
||||
// --- RENAME + NAMING ---
|
||||
// Discord rate limit: 2 channel renames per 10 minutes per channel (see https://discord.com/developers/docs/topics/rate-limits).
|
||||
// When limit is reached we skip the rename and post: "Channel renamed too quickly. Try again <t:unlock:R>."
|
||||
@@ -322,7 +315,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
await channel.send(CONFIG.AUTO_CLOSE_MESSAGE);
|
||||
await channel.send(CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
@@ -356,7 +349,12 @@ async function checkReminders(client) {
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
const message = CONFIG.REMINDER_MESSAGE.replace('{hours}', CONFIG.REMINDER_AFTER_HOURS);
|
||||
const ping = ticket.claimedBy
|
||||
? `<@${ticket.claimedBy}>`
|
||||
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
|
||||
const message = CONFIG.REMINDER_MESSAGE
|
||||
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
|
||||
.replace(/\{ping\}/g, ping);
|
||||
await channel.send(message);
|
||||
|
||||
await Ticket.updateOne(
|
||||
@@ -406,7 +404,6 @@ async function checkAutoUnclaim(client) {
|
||||
|
||||
module.exports = {
|
||||
getNextTicketNumber,
|
||||
saveZammadId,
|
||||
pickTicketCategoryId,
|
||||
createDiscordTicketAsThread,
|
||||
createEmailTicketAsThread,
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Syncs Zammad agent replies to Discord (for Discord tickets) and Gmail (for email tickets).
|
||||
* Polls Zammad ticket articles and pushes new customer-visible Agent replies to the right channel.
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { getZammadTicketArticles } = require('./zammad');
|
||||
const { sendGmailReply } = require('./gmail');
|
||||
const { htmlToTextWithBlocks } = require('../utils');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
function bodyToText(body, contentType) {
|
||||
if (!body) return '';
|
||||
const isHtml = (contentType || '').toLowerCase().includes('html');
|
||||
return isHtml ? htmlToTextWithBlocks(body).trim() : String(body).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run once: find open tickets with Zammad ID, fetch new agent (customer-visible) articles,
|
||||
* post to Discord or send via Gmail, then update lastSyncedZammadArticleId.
|
||||
* @param {import('discord.js').Client} client - Discord client (for posting to ticket channels)
|
||||
*/
|
||||
async function syncZammadReplies(client) {
|
||||
if (!client?.channels) return;
|
||||
|
||||
const tickets = await Ticket.find({
|
||||
zammadTicketId: { $exists: true, $ne: null },
|
||||
status: 'open'
|
||||
})
|
||||
.select('gmailThreadId discordThreadId zammadTicketId lastSyncedZammadArticleId senderEmail subject')
|
||||
.lean();
|
||||
|
||||
for (const ticket of tickets) {
|
||||
try {
|
||||
const articles = await getZammadTicketArticles(ticket.zammadTicketId);
|
||||
// Only agent replies that are customer-visible (not internal notes)
|
||||
const agentReplies = articles.filter(
|
||||
(a) => a.sender === 'Agent' && a.internal === false && a.body
|
||||
);
|
||||
if (agentReplies.length === 0) continue;
|
||||
|
||||
const lastSynced = ticket.lastSyncedZammadArticleId || 0;
|
||||
const newReplies = agentReplies.filter((a) => a.id > lastSynced);
|
||||
const maxId = Math.max(lastSynced, ...agentReplies.map((a) => a.id));
|
||||
|
||||
// First run: just advance cursor so we don't resend existing articles
|
||||
if (newReplies.length === 0) {
|
||||
if (maxId > lastSynced) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { lastSyncedZammadArticleId: maxId } }
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
|
||||
for (const article of newReplies) {
|
||||
const text = bodyToText(article.body, article.content_type);
|
||||
if (!text) continue;
|
||||
|
||||
const fromLabel = article.created_by || 'Support';
|
||||
|
||||
if (isDiscordTicket && ticket.discordThreadId) {
|
||||
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
await channel.send(`**${fromLabel}** (via Zammad):\n${text}`).catch((err) => {
|
||||
console.error('Zammad sync: Discord send failed:', err.message);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Email ticket: send reply via Gmail
|
||||
try {
|
||||
await sendGmailReply(
|
||||
ticket.gmailThreadId,
|
||||
text,
|
||||
ticket.senderEmail,
|
||||
ticket.subject || 'Support',
|
||||
fromLabel,
|
||||
null
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Zammad sync: Gmail send failed:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { lastSyncedZammadArticleId: maxId } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Zammad sync error for ticket', ticket.gmailThreadId, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { syncZammadReplies };
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* Zammad API service – create/close tickets, manage users, post articles.
|
||||
*/
|
||||
const axios = require('axios');
|
||||
const { ZAMMAD } = require('../config');
|
||||
|
||||
const zammadHeaders = () =>
|
||||
ZAMMAD.URL && ZAMMAD.TOKEN
|
||||
? { Authorization: `Token token=${ZAMMAD.TOKEN}`, 'Content-Type': 'application/json' }
|
||||
: null;
|
||||
|
||||
function baseUrl() {
|
||||
return ZAMMAD.URL ? ZAMMAD.URL.replace(/\/+$/, '') : '';
|
||||
}
|
||||
|
||||
async function createZammadTicket({ subject, body, email, name, gameName, gameKey, group, discordUsername }) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN) {
|
||||
console.warn('Zammad not configured; skipping ticket create.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = `${baseUrl()}/api/v1/tickets`;
|
||||
const isDiscordTicket = Boolean(discordUsername);
|
||||
const firstname = isDiscordTicket ? '' : (name || 'Email Customer').split(' ')[0];
|
||||
const lastname = isDiscordTicket ? '' : (name || 'Email Customer').split(' ').slice(1).join(' ') || 'Customer';
|
||||
|
||||
const payload = {
|
||||
title: subject || 'Support',
|
||||
group,
|
||||
customer: {
|
||||
email,
|
||||
firstname: firstname || '–',
|
||||
lastname: lastname || '–',
|
||||
role_ids: [3]
|
||||
},
|
||||
state: 'new',
|
||||
priority: '2 normal',
|
||||
article: {
|
||||
subject: subject || 'Support',
|
||||
body: `Email: ${email}\nGame: ${gameName}\n\nMessage:\n${body}`,
|
||||
type: 'note',
|
||||
internal: false,
|
||||
sender: 'Customer',
|
||||
from: isDiscordTicket ? `${discordUsername} <${email}>` : `${name} <${email}>`
|
||||
}
|
||||
};
|
||||
|
||||
if (gameKey) payload.gameid = gameKey;
|
||||
if (discordUsername) payload.discordusername = String(discordUsername).slice(0, 120);
|
||||
|
||||
const res = await axios.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function closeZammadTicket(zammadTicketId) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadTicketId) return;
|
||||
const url = `${baseUrl()}/api/v1/tickets/${zammadTicketId}`;
|
||||
await axios.patch(url, { state: 'closed' }, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateZammadUserDiscordId(zammadUserId, discordId) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadUserId || !discordId) return;
|
||||
const url = `${baseUrl()}/api/v1/users/${zammadUserId}`;
|
||||
await axios.patch(url, { discord_id: String(discordId) }, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateZammadUser(zammadUserId, attrs) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadUserId || !attrs || Object.keys(attrs).length === 0) return;
|
||||
const url = `${baseUrl()}/api/v1/users/${zammadUserId}`;
|
||||
const body = {};
|
||||
if (attrs.discord_id != null) body.discord_id = String(attrs.discord_id);
|
||||
if (attrs.discord_username != null) body.discord_username = String(attrs.discord_username).slice(0, 120);
|
||||
if (Object.keys(body).length === 0) return;
|
||||
await axios.patch(url, body, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function searchZammadUsers(query) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !query) return [];
|
||||
try {
|
||||
const { data } = await axios.get(`${baseUrl()}/api/v1/users/search`, {
|
||||
params: { query: String(query).trim() },
|
||||
headers: zammadHeaders()
|
||||
});
|
||||
return Array.isArray(data) ? data : data?.users || [];
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function createZammadUser({ email, firstname, lastname, login, discordId, discordUsername }) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !email) return null;
|
||||
const isDiscord = Boolean(discordUsername);
|
||||
const payload = {
|
||||
login: login || email,
|
||||
email: email.trim(),
|
||||
firstname: (firstname || '').trim() || (isDiscord ? '–' : 'Customer'),
|
||||
lastname: (lastname || '').trim() || (isDiscord ? '–' : 'User'),
|
||||
role_ids: [3]
|
||||
};
|
||||
if (discordId) payload.discord_id = String(discordId);
|
||||
if (discordUsername) payload.discord_username = String(discordUsername).slice(0, 120);
|
||||
const { data } = await axios.post(`${baseUrl()}/api/v1/users`, payload, { headers: zammadHeaders() });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function ensureZammadUserForDiscordUser(websiteUser, opts = {}) {
|
||||
if (!websiteUser?.email || !ZAMMAD.URL || !ZAMMAD.TOKEN) return null;
|
||||
const email = String(websiteUser.email).trim().toLowerCase();
|
||||
const discordId = websiteUser.discordID ? String(websiteUser.discordID) : null;
|
||||
const discordUsername = opts.discordUsername ? String(opts.discordUsername).slice(0, 120) : null;
|
||||
const firstname = (websiteUser.firstname || '').trim();
|
||||
const lastname = (websiteUser.lastname || '').trim();
|
||||
let zammadUser = null;
|
||||
try {
|
||||
const list = await searchZammadUsers(email);
|
||||
zammadUser = list.find((u) => (u.email || '').toLowerCase() === email) || null;
|
||||
} catch (e) {
|
||||
console.error('Zammad user search failed:', e.response?.data || e.message);
|
||||
return null;
|
||||
}
|
||||
if (!zammadUser) {
|
||||
try {
|
||||
zammadUser = await createZammadUser({
|
||||
email,
|
||||
firstname: firstname || (discordUsername ? '–' : 'Customer'),
|
||||
lastname: lastname || (discordUsername ? '–' : 'User'),
|
||||
login: email,
|
||||
discordId,
|
||||
discordUsername
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Zammad user create failed:', e.response?.data || e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (zammadUser?.id && (discordId || discordUsername)) {
|
||||
try {
|
||||
await updateZammadUser(zammadUser.id, {
|
||||
...(discordId && { discord_id: discordId }),
|
||||
...(discordUsername && { discord_username: discordUsername })
|
||||
});
|
||||
} catch (_) {
|
||||
/* custom attributes may not exist in Zammad */
|
||||
}
|
||||
}
|
||||
return zammadUser?.id ?? null;
|
||||
}
|
||||
|
||||
async function getZammadTicketArticles(zammadTicketId) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadTicketId) return [];
|
||||
const url = `${baseUrl()}/api/v1/ticket_articles/by_ticket/${zammadTicketId}`;
|
||||
const res = await axios.get(url, {
|
||||
headers: { Authorization: `Token token=${ZAMMAD.TOKEN}` }
|
||||
});
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
|
||||
async function addZammadArticle(zammadTicketId, body, { from: fromDisplay } = {}) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadTicketId) return;
|
||||
const url = `${baseUrl()}/api/v1/ticket_articles`;
|
||||
const payload = {
|
||||
ticket_id: zammadTicketId,
|
||||
body: body || '',
|
||||
content_type: 'text/plain',
|
||||
type: 'note',
|
||||
internal: false,
|
||||
sender: 'Agent'
|
||||
};
|
||||
if (fromDisplay) {
|
||||
payload.subject = `Discord reply from ${fromDisplay}`;
|
||||
}
|
||||
await axios.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createZammadTicket,
|
||||
closeZammadTicket,
|
||||
updateZammadUserDiscordId,
|
||||
updateZammadUser,
|
||||
searchZammadUsers,
|
||||
createZammadUser,
|
||||
ensureZammadUserForDiscordUser,
|
||||
getZammadTicketArticles,
|
||||
addZammadArticle,
|
||||
zammadHeaders
|
||||
};
|
||||
12
utils.js
12
utils.js
@@ -13,6 +13,17 @@ function escapeRegex(str) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Escape for safe use in HTML body (prevents XSS in outgoing emails). */
|
||||
function escapeHtml(str) {
|
||||
if (str == null) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(str) {
|
||||
if (!str) return '';
|
||||
return str
|
||||
@@ -267,6 +278,7 @@ function replaceVariables(template, context = {}) {
|
||||
module.exports = {
|
||||
BLOCK_TAG_REGEX,
|
||||
escapeRegex,
|
||||
escapeHtml,
|
||||
decodeHtmlEntities,
|
||||
htmlToTextWithBlocks,
|
||||
decodeGmailData,
|
||||
|
||||
Reference in New Issue
Block a user