First upload

This commit is contained in:
samkintop
2026-02-17 21:49:58 -06:00
parent 29a13768f7
commit 6821424663
46 changed files with 3179 additions and 14 deletions

5
.codiumignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.cursor/
custombotproject/
toolbox/
*.log

0
.cursorignore Normal file
View File

View File

@@ -2,6 +2,7 @@
# Broccolini Bot Example environment (no secrets)
# Copy to .env and fill in real values. See README for full docs.
# =============================================================================
# Test env: set ENV_FILE=.env.test to load .env.test instead (see docs/setup/ENV_AND_SECURITY.md).
# --- Discord: Core ---
DISCORD_TOKEN= # Bot token from Discord Developer Portal
@@ -55,6 +56,12 @@ MY_EMAIL= # Support inbox email address
# --- Server & URLs ---
NGROK_URL= # Public URL (optional); run ngrok outside this repo
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
# --- bOSScord (support cockpit) ---
# Set BOSSCORD_API_KEY to enable /api (ticket list, thread, send message). Use same key in bOSScord app login.
# BOSSCORD_API_KEY= # e.g. from: openssl rand -hex 32
# BOSSCORD_CORS_ORIGIN=* # Optional; default * (set to bOSScord origin in production)
# --- Database ---
MONGODB_URI= # MongoDB connection string (e.g. mongodb+srv://user:pass@cluster/dbname)

View File

@@ -157,7 +157,7 @@ Create a `.env` file in the repo root (same directory as this README). All confi
> **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).
> **Agent rule:** Changes to `.env` by an AI/agent must **require explicit user confirmation**. Prefer proposing changes to `.env.test` first and migrating to `.env` only after the user approves. See [ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md).
### Discord
@@ -314,7 +314,7 @@ To try config changes without affecting production, use a **test env**. Copy `.e
npm run start:test
```
Other test scripts: `npm run test-mongodb:test`. After confirming behavior in test, migrate only the desired variables to `.env`. See **[ENV_AND_SECURITY.md](docs/ENV_AND_SECURITY.md)** for the full workflow, security checklist, and agent rules.
Other test scripts: `npm run test-mongodb:test`. After confirming behavior in test, migrate only the desired variables to `.env`. See **[ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md)** for the full workflow, security checklist, and agent rules.
To test the MongoDB connection from the repo root: `npm run test-mongodb`.
@@ -505,18 +505,18 @@ Use this endpoint for uptime monitoring or container health probes. Optional: se
## Documentation
Additional guides and reference docs live in **`docs/`**:
Additional guides and reference docs live in **`docs/`**. See [docs/README.md](docs/README.md) for the full index.
| 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 |
| [QUICKSTART](docs/setup/QUICKSTART.md) | Get started in a few minutes: first response, panel, tags, priority |
| [ENV_AND_SECURITY](docs/setup/ENV_AND_SECURITY.md) | Test env workflow, security checklist, agent rules |
| [MONGODB_SETUP](docs/setup/MONGODB_SETUP.md) | MongoDB connection, schemas, and testing |
| [PROJECT_STRUCTURE](docs/setup/PROJECT_STRUCTURE.md) | File and directory layout |
| [PROPOSAL](docs/features/PROPOSAL.md) | Roadmap and possible next steps |
| [PHASE_FEATURES](docs/features/PHASE_FEATURES.md) | Phased feature list and variables |
| [FEATURES_SUMMARY](docs/features/FEATURES_SUMMARY.md) · [NEW_FEATURES](docs/features/NEW_FEATURES.md) | Feature overview and changelog |
| [DISCORD_API_IMPROVEMENTS](docs/api/DISCORD_API_IMPROVEMENTS.md) · [DISCORD_API_VALIDATION](docs/api/DISCORD_API_VALIDATION.md) | Discord API implementation notes |
---

15
api/bosscordClient.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* bOSScord API: reference to the Discord bot client.
* Set in broccolini-discord.js when client fires "ready"; read by bosscord routes.
*/
let botClient = null;
function setBot(client) {
botClient = client;
}
function getBot() {
return botClient;
}
module.exports = { setBot, getBot };

View File

@@ -18,6 +18,8 @@ const { handleDiscordReply } = require('./handlers/messages');
const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkReminders, checkAutoUnclaim } = require('./services/tickets');
const { registerCommands } = require('./commands/register');
const bosscordRoutes = require('./routes/bosscord');
const { setBot } = require('./api/bosscordClient');
const { poll } = require('./gmail-poll');
const { setClient: setDebugClient } = require('./services/debugLog');
@@ -28,7 +30,7 @@ const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTex
// --- VALIDATE CONFIG ---
if (!CONFIG.DISCORD_TOKEN) {
console.error('DISCORD_TOKEN is not set in .env');
console.error('DISCORD_TOKEN or DISCORD_BOT_TOKEN is not set in .env');
process.exit(1);
}
if (!CONFIG.TICKET_CATEGORY_ID) {
@@ -114,6 +116,14 @@ client.once('ready', async () => {
}
await connectMongoDB(process.env.MONGODB_URI);
setDebugClient(client);
setBot(client);
if (process.env.BOSSCORD_API_KEY) {
app.use('/api', bosscordRoutes);
app.use('/api', (err, req, res, next) => {
console.error('bOSScord API error:', err && err.stack ? err.stack : err);
res.status(500).json({ error: 'Internal server error' });
});
}
console.log(`Broccolini Bot active on port ${CONFIG.PORT}`);
const guild = CONFIG.DISCORD_GUILD_ID
@@ -160,7 +170,9 @@ client.once('ready', async () => {
client.login(CONFIG.DISCORD_TOKEN);
const app = express();
app.use(express.json());
app.get('/', (req, res) => res.send('Active'));
// Mount bOSScord API only after MongoDB is connected (inside ready), to avoid 500 on first request
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
app.listen(CONFIG.PORT, healthcheckHost, () => {
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);

View File

@@ -13,14 +13,25 @@ const dotenvExpand = require('dotenv-expand');
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' });
let 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);
// If no ENV_FILE, also load repo root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env)
if (!envPath) {
const rootEnv = path.resolve(process.cwd(), '..', '.env');
const rootParsed = dotenv.config({ path: rootEnv });
if (!rootParsed.error && rootParsed.parsed) {
for (const [k, v] of Object.entries(rootParsed.parsed)) {
if (v != null && String(v).trim() !== '') process.env[k] = v;
}
dotenvExpand.expand(rootParsed);
}
}
const CONFIG = {
DISCORD_TOKEN: process.env.DISCORD_TOKEN,
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '')

View File

@@ -0,0 +1,250 @@
# Critical Files & How Broccolini Bot Works
This document identifies the **most critical files** for understanding the repo and gives a **thorough explanation** of how the bot works end-to-end.
---
## Most Critical Files (Read These First)
These are the files that give someone the fastest path to understanding the repo. Read in roughly this order.
### 1. [**README.md**](../README.md) (repo root)
- **Why:** Single source of truth for features, architecture diagram, config, commands, and troubleshooting.
- **What you get:** High-level picture, env vars, Discord commands, tag/panel systems, database schema summary, and links to other docs.
### 2. [**broccolini-discord.js**](../broccolini-discord.js) (entry point)
- **Why:** Where the bot starts and where all major pieces are wired together.
- **What you get:** Discord client setup, `interactionCreate` routing (buttons → commands → modals → context menus → autocomplete), `messageCreate` → Gmail reply handler, and `ready` logic: MongoDB connect, command registration, Gmail poll start, and background job intervals (auto-close, reminders, auto-unclaim). Also mounts the Express healthcheck and optional bOSScord API.
### 3. [**config.js**](../config.js)
- **Why:** All runtime configuration comes from here (env + defaults).
- **What you get:** Single `CONFIG` object: Discord IDs, Gmail/MongoDB settings, automation toggles, message templates, button labels, priority/game lists, and guild-specific options. Test env is supported via `ENV_FILE=.env.test`.
### 4. [**models.js**](../models.js) (Broccolini Bot section, ~line 793+)
- **Why:** Data model defines what the bot persists and how tickets are represented.
- **What you get:** Mongoose schemas for **Ticket** (gmailThreadId, discordThreadId, senderEmail, status, claimedBy, priority, escalation, etc.), **TicketCounter**, **Transcript**, **Tag**, **CloseRequest**, **GuildSettings**. Earlier in the file: **User**, **Host**, and other game/hosting models used by `/accountinfo` and external integrations.
### 5. [**gmail-poll.js**](../gmail-poll.js)
- **Why:** This is the “email → Discord” bridge: how support emails become ticket channels.
- **What you get:** `poll(client)` runs every 30s: lists unread primary inbox, skips messages from own address, parses From/Subject/body, strips quotes/footers, detects game from `GAME_LIST`, checks ticket limits and rate limits, gets next ticket number, creates Discord channel (or thread) and embed with Claim/Close buttons, saves Ticket + optional Transcript in MongoDB, marks email read. Overflow categories when a category hits 50 channels.
### 6. [**handlers/messages.js**](../handlers/messages.js)
- **Why:** This is the “Discord → email” bridge: staff messages in a ticket become Gmail replies.
- **What you get:** `handleDiscordReply(message)`: ignores bots and non-ticket channels; looks up Ticket by `discordThreadId`; skips if ticket is Discord-origin (`gmailThreadId.startsWith('discord-')`); for email tickets, gets Gmail thread, finds last customer message, builds reply with staff name and content, calls `sendGmailReply`, and updates `lastActivity`.
### 7. [**services/gmail.js**](../services/gmail.js)
- **Why:** All Gmail API usage and outbound email logic.
- **What you get:** OAuth2 client via `getGmailClient()`; `sendGmailReply()` (threaded reply with HTML, In-Reply-To/References); `sendTicketClosedEmail()` for closure notifications; optional `sendTicketNotificationEmail()` (e.g. priority high). Raw MIME construction and `users.messages.send`.
### 8. [**services/tickets.js**](../services/tickets.js)
- **Why:** Core ticket lifecycle and Discord channel/thread creation.
- **What you get:** Ticket numbers (`getNextTicketNumber`), channel naming and Discord rate limit handling (2 renames per 10 min), ticket limits and overflow category selection, rate limit for ticket creation per user, `createEmailTicketAsThread` / `createDiscordTicketAsThread`, auto-close/reminder/auto-unclaim jobs, and helpers like `updateTicketActivity`, `canRename`, `makeTicketName`.
### 9. [**handlers/buttons.js**](../handlers/buttons.js)
- **Why:** Every button and ticket modal goes through here.
- **What you get:** “Open Ticket” panel → modal (email, game, description); email routing buttons (thread vs category); Claim / Unclaim / Close (including close confirmation flow); priority and tag selects; escalation/deescalation; and `handleTicketModal` for creating a ticket from the panel. Integrates with `commands.js` for escalation and with `tickets.js`/`gmail.js` for close and notifications.
### 10. [**handlers/commands.js**](../handlers/commands.js)
- **Why:** All slash commands and context menus are implemented here.
- **What you get:** Staff check (`requireStaffRole`), then routing for `/claim`, `/unclaim`, `/close`, `/priority`, `/topic`, `/escalate`, `/deescalate`, `/add`, `/remove`, `/transfer`, `/move`, `/tag`, `/response *`, `/panel`, `/email-routing`, `/accountinfo`, `/search`, `/stats`, `/backup`, `/export`, `/help`, and context menu “Create Ticket From Message”. Uses `tickets.js`, `guildSettings`, analytics, and accountinfo/setup handlers.
### 11. [**commands/register.js**](../commands/register.js)
- **Why:** Defines and registers every slash command and context menu with Discord.
- **What you get:** Full list of command names, options, permissions, and context types. Run at startup so Discord has the latest command definitions.
### 12. [**db-connection.js**](../db-connection.js)
- **Why:** MongoDB is required; this is the only place that connects and loads models.
- **What you get:** `connectMongoDB(uri)`, requires `models.js`, and wires `error` / `disconnected` / `reconnected` for resilience.
### 13. [**utils.js**](../utils.js)
- **Why:** Shared parsing and formatting used by Gmail poll, Gmail service, and commands.
- **What you get:** `getCleanBody`, `extractRawEmail`, `stripEmailQuotes`, `stripMobileFooter`, `detectGame` (from subject/body vs `GAME_LIST`), `replaceVariables` for tag/response templates (`{ticket.user}`, `{staff.name}`, etc.), `getPriorityEmoji`, `getFormattedDate`, `escapeHtml`, `htmlToTextWithBlocks`.
### 14. [**utils/ticketComponents.js**](../utils/ticketComponents.js)
- **Why:** Central place for Claim/Unclaim/Close (and related) button rows and embeds.
- **What you get:** `getTicketActionRow()` and related builders so ticket channels and panels show consistent buttons and styling.
### Supporting but still important
- [**services/guildSettings.js**](../services/guildSettings.js) Guild-specific settings (e.g. email routing: thread vs category), cached and persisted in MongoDB.
- [**services/debugLog.js**](../services/debugLog.js) Structured logging and optional Discord debugging channel.
- [**handlers/accountinfo.js**](../handlers/accountinfo.js) `/accountinfo` and lookup logic (website user / Discord link).
- [**handlers/analytics.js**](../handlers/analytics.js) In-memory interaction/error tracking and `/stats`.
- [**handlers/setup.js**](../handlers/setup.js) Guild setup flow (buttons/modals/selects).
- [**game-options.json**](../game-options.json) Game list used for dropdowns/options.
- [**QUICKSTART.md**](QUICKSTART.md) Short path to first reply, panel, tags, priority.
- [**ENV_AND_SECURITY.md**](ENV_AND_SECURITY.md) Test env workflow and security/agent rules.
- [**PROJECT_STRUCTURE.md**](PROJECT_STRUCTURE.md) File/directory layout reference.
---
## How the Bot Works (End-to-End)
### Overview
Broccolini Bot is a **Node.js support-ticket bot** that connects **Gmail**, **Discord**, and **MongoDB**. Incoming support emails become Discord ticket channels (or threads); staff reply in Discord and their messages are sent back to the customer via Gmail. Tickets can also be created from Discord via a panel (no email). All ticket state is stored in MongoDB.
---
### Startup sequence
1. **Load config**
[config.js](../config.js) loads `.env` (or `ENV_FILE`), runs dotenv-expand, and exports `CONFIG`.
2. **Validate env**
[broccolini-discord.js](../broccolini-discord.js) checks required vars (e.g. `DISCORD_TOKEN`, `TICKET_CATEGORY_ID`, Gmail OAuth). Missing required ones cause exit.
3. **Create Discord client**
Client is created with intents: Guilds, GuildMessages, MessageContent, GuildMembers; Partials.Channel for ticket channels that might not be in cache.
4. **Register event handlers**
- **`interactionCreate`** Buttons (accountinfo, setup, ticket actions), modals (setup, ticket creation), slash commands, context menus, autocomplete. Order matters: prefix checks (e.g. accountinfo, setup) run before generic button/command handlers.
- **`messageCreate`** `handleDiscordReply`: Discord → Gmail for staff messages in ticket channels.
5. **`ready`**
- Connect MongoDB via [db-connection.js](../db-connection.js) (and load [models.js](../models.js)).
- Set debug log client and bOSScord API client.
- If `BOSSCORD_API_KEY` is set, mount `/api` routes (e.g. bOSScord).
- Call `registerCommands()` so slash commands and context menus are registered for the guild.
- Start Gmail poll: `poll(client)` immediately and then `setInterval(..., 30000)`.
- If enabled: start hourly auto-close, 30-minute reminders, hourly auto-unclaim.
- Express server is already created; it listens on `CONFIG.PORT` and serves `GET /``"Active"` for healthchecks.
6. **No Gmail/MongoDB**
If `MONGODB_URI` is missing, the bot exits in `ready`. Gmail credentials are validated at startup but polling can fail later if tokens are bad.
---
### Email → Discord (new ticket from email)
1. **Gmail poll** ([gmail-poll.js](../gmail-poll.js))
- Every 30s, `poll(client)` uses Gmail API `users.messages.list` with `is:unread category:primary`.
- For each message, fetches full message, parses From/Subject/body. Skips if From is the support address (and marks it read).
- Extracts sender email and name; cleans body (strip reply quotes, mobile footers) via [utils.js](../utils.js).
- Detects game from subject/body using `GAME_LIST` (`utils.detectGame`).
- Checks global and per-category ticket limits and per-user rate limit (`services/tickets.js`).
- Gets next ticket number per sender from `TicketCounter` (`getNextTicketNumber`).
- Decides where to create the ticket: **thread** vs **category channel** from `getEmailRouting()` (guild setting, can be set via `/email-routing`).
- Creates the Discord channel or thread, posts an embed (subject, sender, game, ticket number) and action row (Claim, Close) from `ticketComponents.js`.
- Saves a **Ticket** in MongoDB: `gmailThreadId`, `discordThreadId` (channel or thread id), `senderEmail`, `subject`, `ticketNumber`, game, status `open`, etc. Optionally creates **Transcript** placeholder.
- Marks the Gmail message read so it is not processed again.
2. **Overflow**
Discord allows 50 channels per category. If the main ticket category is full, the bot uses `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` (and similar for Discord-origin tickets) to pick another category.
---
### Discord → Email (staff reply)
1. **Message event**
When a message is sent in a channel, `handleDiscordReply` in [handlers/messages.js](../handlers/messages.js) runs.
2. **Filter**
Ignores bots and interactions. Finds a Ticket by `discordThreadId === channel.id`. If none, or if `gmailThreadId.startsWith('discord-')`, does nothing (Discord-origin tickets have no Gmail thread).
3. **Build reply**
Uses Gmail API `users.threads.get` to get the thread. Finds the last message from the customer (not from support). Reads To/Reply-To, Subject, Message-ID.
4. **Send**
`sendGmailReply(threadId, content, recipientEmail, subject, discordUser, messageId)` in [services/gmail.js](../services/gmail.js) builds HTML (staff name, content, logo/signature), sets In-Reply-To/References for threading, and calls `users.messages.send` with `threadId` so the reply stays in the same Gmail thread.
5. **Activity**
`updateTicketActivity(ticket.gmailThreadId)` updates the tickets `lastActivity` for auto-close and reminder logic.
---
### Ticket creation from Discord (panel)
1. **Panel**
Staff runs `/panel` (optionally with channel, type, title, description). The bot sends a message with an “Open Ticket” button (and optional styling).
2. **Button click**
User clicks the button. [handlers/buttons.js](../handlers/buttons.js) shows a modal with: Account Email, Game, Description (and possibly priority).
3. **Modal submit**
`handleTicketModal` runs. Validates and applies rate limit (`checkTicketCreationRateLimit`). Creates a Ticket in MongoDB with `gmailThreadId = 'discord-' + ...` (no real Gmail thread). Gets next ticket number (by email or Discord user). Creates a Discord channel or thread (depending on panel type and guild settings), posts welcome embed and Claim/Close buttons, saves `discordThreadId` and other fields. No Gmail is involved for this ticket; `handleDiscordReply` explicitly skips when `gmailThreadId.startsWith('discord-')`.
---
### Claim / Unclaim / Close
- **Claim** (button or `/claim`)
Ticket is updated with `claimedBy` (user id or name). Channel may be renamed (respecting Discords 2 renames per 10 min). Claimed message is posted (template from CONFIG).
- **Unclaim** (button or `/unclaim`)
`claimedBy` is cleared; channel rename and message as above.
- **Close** (button or `/close`)
Close button often triggers a confirmation (e.g. “Are you sure?” with Confirm/Cancel). On confirm (or `/force-close`): build transcript of the channel, post it to the transcript channel, send closure email for **email tickets** via `sendTicketClosedEmail`, delete the Discord channel/thread, set ticket status to `closed` and clean up CloseRequest. Discord-origin tickets get no Gmail closure email.
---
### Automation (background jobs)
- **Auto-close**
If `AUTO_CLOSE_ENABLED`, `checkAutoClose` runs hourly. Finds open tickets whose `lastActivity` is older than `AUTO_CLOSE_AFTER_HOURS`. For each, same flow as manual close (transcript, closure email if email ticket, delete channel, update ticket).
- **Reminders**
If `REMINDER_ENABLED`, `checkReminders` runs every 30 minutes. Finds open tickets inactive longer than `REMINDER_AFTER_HOURS` and not yet reminded. Sends reminder message to the channel and sets `reminderSent`.
- **Auto-unclaim**
If `AUTO_UNCLAIM_ENABLED`, `checkAutoUnclaim` runs hourly. Clears `claimedBy` on tickets that have been inactive for `AUTO_UNCLAIM_AFTER_HOURS`.
All of these use the Ticket models `lastActivity` (and optional `reminderSent`) and live in [services/tickets.js](../services/tickets.js).
---
### Tags and saved responses
- **Tags**
`/tag` sets a ticket category (e.g. Server Down, Billing). Stored on the ticket and can be used in naming or display. Tag options come from `CONFIG.TICKET_TAGS` (from config / env).
- **Saved responses**
Stored in MongoDB (**Tag** collection for response name/content). `/response create|edit|delete|list|send`. When sending, `utils.replaceVariables` substitutes `{ticket.user}`, `{staff.name}`, `{date}`, etc. Autocomplete for response names is provided in `handlers/commands.js`.
---
### Priority and escalation
- **Priority**
If `PRIORITY_ENABLED`, tickets have low/normal/medium/high. Stored on Ticket; embeds and channel naming can show priority emoji. Optional: send email when set to high (`sendTicketNotificationEmail`).
- **Escalation**
`/escalate` and `/deescalate` (or buttons) change `escalationTier` (0 → 1 → 2) and move the channel to escalation categories (e.g. `EMAIL_ESCALATED_CATEGORY_ID`, `DISCORD_ESCALATED2_CHANNEL_ID`). Channel rename and “escalated” message are posted. Optional escalation notification email.
---
### Account info and other commands
- **`/accountinfo`**
Looks up a user by email or Discord ID (uses **User** and related models from `models.js`). Can post results to a dedicated channel; handler in `handlers/accountinfo.js`.
- **`/email-routing`**
Toggles where new **email** tickets are created: thread under a parent channel or channel in a category. Value is stored per guild in **GuildSettings** and read by [gmail-poll.js](../gmail-poll.js) via `getEmailRouting()`.
- **`/panel`**
Sends a message with an “Open Ticket” button; panel type (thread vs channel) and target channel are options.
- **`/search`, `/backup`, `/export`**
Query or export tickets (by status, limit, etc.) and post results (e.g. to backup channel).
- **`/stats`**
Returns in-memory analytics (interactions, errors) from `handlers/analytics.js`.
---
### Data flow summary
- **Gmail → Discord:** [gmail-poll.js](../gmail-poll.js) (poll) → [services/gmail.js](../services/gmail.js) (read), [services/tickets.js](../services/tickets.js) (create channel + Ticket), [utils.js](../utils.js) (parse/detect game).
- **Discord → Gmail:** [handlers/messages.js](../handlers/messages.js) → [services/gmail.js](../services/gmail.js) (`sendGmailReply`), [services/tickets.js](../services/tickets.js) (`updateTicketActivity`).
- **Discord-only ticket:** [handlers/buttons.js](../handlers/buttons.js) (modal) → [services/tickets.js](../services/tickets.js) (create channel + Ticket with `gmailThreadId: 'discord-...'`).
- **All interactions:** [broccolini-discord.js](../broccolini-discord.js) (routing) → [handlers/buttons.js](../handlers/buttons.js) or [handlers/commands.js](../handlers/commands.js) → [services/tickets.js](../services/tickets.js), [services/guildSettings.js](../services/guildSettings.js), [services/gmail.js](../services/gmail.js), and [models.js](../models.js) (Mongoose).
---
### Dependencies (high level)
- **discord.js** Client, channels, embeds, buttons, modals, slash commands, context menus.
- **googleapis** Gmail API (OAuth2, list/get messages, send, threads).
- **mongoose** MongoDB connection and Broccolini Bot + game/hosting models.
- **express** Healthcheck server and bOSScord API mount.
For full setup, config, and troubleshooting, use [README.md](../README.md), [QUICKSTART.md](QUICKSTART.md), and [ENV_AND_SECURITY.md](ENV_AND_SECURITY.md).

50
docs/README.md Normal file
View File

@@ -0,0 +1,50 @@
# Broccolini Bot documentation
Docs are grouped by topic. Paths below are relative to this folder.
## Setup & config
| Doc | Description |
|-----|-------------|
| [setup/ENV_AND_SECURITY.md](setup/ENV_AND_SECURITY.md) | Test env workflow, security checklist, agent rules |
| [setup/MONGODB_SETUP.md](setup/MONGODB_SETUP.md) | MongoDB connection, schemas, and testing |
| [setup/QUICKSTART.md](setup/QUICKSTART.md) | Get started in a few minutes |
| [setup/PROJECT_STRUCTURE.md](setup/PROJECT_STRUCTURE.md) | File and directory layout |
## Architecture
| Doc | Description |
|-----|-------------|
| [architecture/CRITICAL_FILES_AND_HOW_IT_WORKS.md](architecture/CRITICAL_FILES_AND_HOW_IT_WORKS.md) | Critical files, startup sequence, email ↔ Discord flow |
| [architecture/COMMANDS_ANALYSIS.md](architecture/COMMANDS_ANALYSIS.md) | Commands analysis |
## Features & roadmap
| Doc | Description |
|-----|-------------|
| [features/PHASE_FEATURES.md](features/PHASE_FEATURES.md) | Phased feature list and variables |
| [features/FEATURES_SUMMARY.md](features/FEATURES_SUMMARY.md) · [features/NEW_FEATURES.md](features/NEW_FEATURES.md) | Feature overview and changelog |
| [features/IMPLEMENTATION_SUMMARY.md](features/IMPLEMENTATION_SUMMARY.md) | Implementation summary |
| [features/PROPOSAL.md](features/PROPOSAL.md) | Roadmap and possible next steps |
| [features/UPGRADE_COMPLETE.md](features/UPGRADE_COMPLETE.md) | Upgrade notes |
## API
| Doc | Description |
|-----|-------------|
| [api/DISCORD_API_IMPROVEMENTS.md](api/DISCORD_API_IMPROVEMENTS.md) · [api/DISCORD_API_VALIDATION.md](api/DISCORD_API_VALIDATION.md) | Discord API implementation notes |
## Analytics
| Doc | Description |
|-----|-------------|
| [analytics/Part 1 Batch Analytics Report.md](analytics/Part%201%20Batch%20Analytics%20Report.md) · [.html](analytics/Part%201%20Batch%20Analytics%20Report.html) | Part 1 batch analytics |
| [analytics/Part 2 Batch Analytics Report.md](analytics/Part%202%20Batch%20Analytics%20Report.md) | Part 2 batch analytics |
| [analytics/Part 1 Analysis.md](analytics/Part%201%20Analysis.md) · [analytics/Part 1 Prompting.md](analytics/Part%201%20Prompting.md) | Analysis and prompting notes |
## Reference
| Doc | Description |
|-----|-------------|
| [reference/game-list.md](reference/game-list.md) | Game list for tickets |
| [reference/regex-and-games.md](reference/regex-and-games.md) | Regex and games reference |

View File

View File

@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mode C: Batch Analytics Report</title>
<style>
:root {
--bg: #fafbfc;
--paper: #ffffff;
--text: #1a1d21;
--text-muted: #57606a;
--accent: #0969da;
--accent-soft: #ddf4ff;
--border: #d0d7de;
--table-stripe: #f6f8fa;
--code-bg: #f0f2f5;
--font-sans: 'Segoe UI', system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
--radius: 6px;
--shadow: 0 1px 3px rgba(0,0,0,.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 2rem 1.5rem 3rem;
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
.report {
max-width: 820px;
margin: 0 auto;
background: var(--paper);
padding: 2.5rem 3rem;
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.report-header {
border-bottom: 2px solid var(--border);
padding-bottom: 1.5rem;
margin-bottom: 2rem;
}
.report-header h1 {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 600;
color: var(--text);
}
.report-meta {
font-size: 0.9rem;
color: var(--text-muted);
}
.report-meta p { margin: 0.25rem 0; }
h2 {
font-size: 1.2rem;
font-weight: 600;
margin: 2rem 0 1rem;
padding-bottom: 0.35rem;
color: var(--text);
border-bottom: 1px solid var(--border);
}
h2:first-of-type { margin-top: 0; }
p { margin: 0.75rem 0; }
.narrative, .recommendation { font-style: normal; }
strong { font-weight: 600; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin: 1rem 0;
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
th, td {
padding: 0.6rem 1rem;
text-align: left;
border: 1px solid var(--border);
}
th {
background: var(--table-stripe);
font-weight: 600;
color: var(--text);
}
tr:nth-child(even) { background: var(--table-stripe); }
tr:hover { background: #eef2f7; }
pre, code {
font-family: var(--font-mono);
font-size: 0.85em;
}
pre {
background: var(--code-bg);
padding: 1rem 1.25rem;
border-radius: var(--radius);
overflow-x: auto;
margin: 1rem 0;
border: 1px solid var(--border);
}
code { padding: 0.15em 0.4em; background: var(--code-bg); border-radius: 4px; }
ul { margin: 0.75rem 0; padding-left: 1.5rem; }
li { margin: 0.35rem 0; }
hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
@media print {
body { background: #fff; padding: 0; }
.report {
max-width: none;
box-shadow: none;
padding: 0;
}
h2 { page-break-after: avoid; }
table { page-break-inside: avoid; }
}
</style>
</head>
<body>
<div class="report">
<header class="report-header">
<h1>Mode C: Batch analytics report</h1>
<div class="report-meta">
<p><strong>Source:</strong> <code>Discord Ticket Transcripts/Drive2/</code></p>
<p><strong>Computed:</strong> From transcript HTML (metadata + decoded base64 message payloads).</p>
<p><strong>Guide:</strong> Part 1 Analysis (Transcript analytics schemas, Broccolini support section).</p>
<p><strong>Tool:</strong> <code>scripts/batch_transcript_analytics.py</code></p>
</div>
<p style="margin-top: 1rem;">Analytics below are <strong>perticket and aggregate</strong> across 722 transcripts. Dimensions that require full Mode A extraction (issue categories, tags, wiki success/failure, intake gaps, frequency/impact, resolution status, email forgotten/misspelled) are noted; tables use parser-derived data where available.</p>
</header>
<h2>1. Volume and scope</h2>
<table>
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
<tbody>
<tr><td>Total tickets</td><td>722</td></tr>
<tr><td>Transcripts with parse errors</td><td>0</td></tr>
<tr><td>Tickets with "Ticket closed" / "Transcript saving" in payload</td><td>722 (100%)</td></tr>
<tr><td>Tickets with claimed channel name (staff claimed)</td><td>671 (93%)</td></tr>
<tr><td>Tickets with escalation mentioned in text</td><td>1</td></tr>
</tbody>
</table>
<p class="narrative"><strong>Narrative:</strong> The Drive2 batch is fully parseable. Virtually all tickets show a close/saving event; 93% have a claimed channel, indicating most tickets were claimed by staff before closure. Escalations are rare in this set.</p>
<hr />
<h2>2. Game detection and game_or_server (heuristic)</h2>
<p>From decoded form + messages: text buffer scanned for canonical game names and aliases (Part 1 Analysis §5). <code>game_or_server</code> routing buckets would require Mode A (Valheim | Rust main | MC modded | MC vanilla | Other).</p>
<table>
<thead><tr><th>game_detected (heuristic)</th><th>Count</th><th>%</th></tr></thead>
<tbody>
<tr><td>Project Zomboid</td><td>257</td><td>35.6</td></tr>
<tr><td>Minecraft</td><td>179</td><td>24.8</td></tr>
<tr><td>Satisfactory</td><td>79</td><td>10.9</td></tr>
<tr><td>Palworld</td><td>46</td><td>6.4</td></tr>
<tr><td>Not Mentioned</td><td>45</td><td>6.2</td></tr>
<tr><td>Enshrouded</td><td>18</td><td>2.5</td></tr>
<tr><td>ARK: Survival Evolved</td><td>18</td><td>2.5</td></tr>
<tr><td>7 Days to Die</td><td>17</td><td>2.4</td></tr>
<tr><td>Valheim</td><td>14</td><td>1.9</td></tr>
<tr><td>DayZ</td><td>10</td><td>1.4</td></tr>
<tr><td>FiveM</td><td>8</td><td>1.1</td></tr>
<tr><td>Core Keeper</td><td>6</td><td>0.8</td></tr>
<tr><td>Vintage Story</td><td>5</td><td>0.7</td></tr>
<tr><td>Rust</td><td>5</td><td>0.7</td></tr>
<tr><td>Factorio</td><td>5</td><td>0.7</td></tr>
<tr><td>V Rising</td><td>3</td><td>0.4</td></tr>
<tr><td>ECO</td><td>3</td><td>0.4</td></tr>
<tr><td>Necesse</td><td>4</td><td>0.6</td></tr>
</tbody>
</table>
<p class="narrative"><strong>Narrative:</strong> Project Zomboid and Minecraft dominate; Satisfactory and Palworld are next. About 6% have no game detected from text. Full <code>game_or_server</code> (MC modded vs vanilla, Rust main, etc.) needs perticket Mode A extraction.</p>
<hr />
<h2>3. Issue categories and tags</h2>
<p><strong>Issue categories</strong> (Availability, Connectivity, Billing, Data/saves, Configuration/mods) and <strong>TICKET_TAGS</strong> (Server Down, Stuck Restarting, Can't Connect, Server Lag, Billing, Refund Request, Mod Help, Backup Restore, World/Save, Server Config) require Mode A extraction from each transcript. No aggregate table is computed from the batch parser.</p>
<p class="recommendation"><strong>Recommendation:</strong> Run Mode A on all transcripts (or a sample), then aggregate <code>issue_types</code> and suggested tags into counts and top tags per game.</p>
<hr />
<h2>4. Message count and conversation shape</h2>
<table>
<thead><tr><th>Messages (header)</th><th>Number of tickets</th></tr></thead>
<tbody>
<tr><td>36</td><td>151</td></tr>
<tr><td>710</td><td>128</td></tr>
<tr><td>1115</td><td>95</td></tr>
<tr><td>1622</td><td>88</td></tr>
<tr><td>2335</td><td>78</td></tr>
<tr><td>3660</td><td>45</td></tr>
<tr><td>61+</td><td>137</td></tr>
</tbody>
</table>
<p>Summary stats (from header): Min 3, max 356 messages per ticket; majority in the 322 range. Backandforth turns, duration, and "staff asked for more info repeatedly" require Mode A.</p>
<p class="narrative"><strong>Narrative:</strong> Conversation length is skewed toward short (310 messages) and mid (1135); a smaller set of tickets are long (60+ messages), likely complex or multi-step resolutions.</p>
<hr />
<h2>5. Attachments (saved / skipped)</h2>
<table>
<thead><tr><th>Attachments saved</th><th>Number of tickets</th></tr></thead>
<tbody>
<tr><td>0</td><td>325</td></tr>
<tr><td>1</td><td>169</td></tr>
<tr><td>2</td><td>81</td></tr>
<tr><td>3</td><td>55</td></tr>
<tr><td>4</td><td>30</td></tr>
<tr><td>5+</td><td>62</td></tr>
</tbody>
</table>
<table>
<thead><tr><th>Attachments skipped</th><th>Number of tickets</th></tr></thead>
<tbody>
<tr><td>0</td><td>695</td></tr>
<tr><td>1</td><td>16</td></tr>
<tr><td>2</td><td>6</td></tr>
<tr><td>3</td><td>3</td></tr>
<tr><td>4</td><td>2</td></tr>
</tbody>
</table>
<p class="narrative"><strong>Narrative:</strong> About 45% of tickets have at least one attachment saved; most have none skipped. Skipped reasons and mentions_screenshots/clips/logs require Mode A.</p>
<hr />
<h2>6. Staff involvement (from payload)</h2>
<p>Staff identified by Broccolini support user IDs (Part 1 Analysis §10.1) appearing in message payloads.</p>
<table>
<thead><tr><th>Staff involved (count per ticket)</th><th>Number of tickets</th></tr></thead>
<tbody>
<tr><td>0</td><td>152</td></tr>
<tr><td>1</td><td>545</td></tr>
<tr><td>2</td><td>25</td></tr>
</tbody>
</table>
<p class="narrative"><strong>Narrative:</strong> Most tickets have exactly one staff member in the payload; 152 have no staff ID in messages (e.g. Ticket Toolonly or unclaimed). "Tickets claimed per member" and firstresponse time need claim/unclaim message parsing (channel name gives claim attribution; full workload per member needs Mode A or claim-event parsing).</p>
<hr />
<h2>7. User count (participants per ticket)</h2>
<table>
<thead><tr><th>User count (header)</th><th>Number of tickets</th></tr></thead>
<tbody>
<tr><td>2</td><td>70</td></tr>
<tr><td>3</td><td>582</td></tr>
<tr><td>4</td><td>44</td></tr>
<tr><td>5</td><td>8</td></tr>
<tr><td>6</td><td>2</td></tr>
</tbody>
</table>
<p class="narrative"><strong>Narrative:</strong> Most tickets have 3 participants (requester + 1 staff + Ticket Tool); 4+ participants suggest multi-staff or extra users in thread.</p>
<hr />
<h2>8. Wiki usage and wikilinked outcomes</h2>
<p><strong>wiki_articles_posted</strong>, <strong>wiki_solved_issue</strong> (true / false / unclear), and stafflinked outcomes (user_wanted_broccolini_to_do_it, user_wanted_broccolini_but_walkthrough) require Mode A extraction. No aggregate table from the batch parser.</p>
<p class="recommendation"><strong>Recommendation:</strong> After Mode A, aggregate: (1) tickets where wiki_solved_issue = true / false / unclear; (2) per support member: wiki posts that solved vs did not, "do it for me" vs walkthrough counts.</p>
<hr />
<h2>9. Email analytics</h2>
<p>Parser did not detect "Account Email" + email in the same decoded block in this run. <strong>Email analytics</strong> (email_forgotten, email_misspelled, email_didnt_link, email_corrected) require Mode A extraction from form embeds and message text.</p>
<hr />
<h2>10. Frequency / impact distributions</h2>
<p><strong>frequency</strong> (once | sometimes | every_time | unclear) and <strong>impact</strong> (minor | moderate | severe | blocked | unclear) require inference from transcript wording (Mode A). No aggregate table from the batch parser.</p>
<hr />
<h2>11. Resolution patterns</h2>
<p>From parser: all 722 tickets contain "Ticket closed" or "Transcript saving" in the payload. <strong>status</strong> (resolved | unresolved | escalated | unclear) and <strong>relied_on</strong> (logs | mod_updates | staff_action | other) require Mode A. One ticket mentions escalation in text.</p>
<p class="narrative"><strong>Narrative:</strong> All transcripts represent closed/saved tickets; resolution outcome and what resolution relied on need perticket extraction.</p>
<hr />
<h2>12. Intake gaps</h2>
<p>Perticket intake_gaps (account_contact, issue_type, reproduction, environment, attachments, priority, rules) each as complete | partial | missing require Mode A. No aggregate table from the batch parser.</p>
<p class="recommendation"><strong>Recommendation:</strong> After Mode A, report % complete / partial / missing per dimension to target form and template improvements.</p>
<hr />
<h2>13. Recurring analytics (Broccolini support section)</h2>
<p>From the batch parser we have:</p>
<ul>
<li><strong>Tickets per game_detected (heuristic):</strong> see §2.</li>
<li><strong>Claimed channel share:</strong> 671/722 (93%).</li>
<li><strong>Staff involved count per ticket:</strong> see §6.</li>
</ul>
<p><strong>Require Mode A or claim parsing:</strong></p>
<ul>
<li>Tickets claimed per member (from claim/unclaim messages or channel name).</li>
<li>First response time, reopens, escalations.</li>
<li>Tag distribution, repeat customers, sentiment toward staff.</li>
<li>Wikilinked outcomes per member (§9.2).</li>
</ul>
<hr />
<h2>14. How to reproduce and extend</h2>
<ol>
<li><strong>Run batch parser (this report's source):</strong>
<pre>python3 scripts/batch_transcript_analytics.py "Discord Ticket Transcripts/Drive2"</pre>
For a subfolder or Drive:
<pre>python3 scripts/batch_transcript_analytics.py "Discord Ticket Transcripts/Drive"</pre>
</li>
<li><strong>Full Mode C tables:</strong> Run Mode A extraction on each transcript (or a sample), collect JSON, then aggregate by issue categories, tags, game_or_server, wiki_solved_issue, intake_gaps, frequency/impact, resolution status, and email analytics. Use Part 1 Analysis and <code>docs/TICKET-ANALYTICS-SCHEMA-PROMPTING.md</code> as the schema source of truth.</li>
</ol>
</div>
</body>
</html>

View File

@@ -0,0 +1,195 @@
# Mode C: Batch analytics report
**Source:** `Discord Ticket Transcripts/Drive2/`
**Computed:** From transcript HTML (metadata + decoded base64 message payloads).
**Guide:** Part 1 Analysis (Transcript analytics schemas, Broccolini support section).
**Tool:** `scripts/batch_transcript_analytics.py`
Analytics below are **perticket and aggregate** across 722 transcripts. Dimensions that require full Mode A extraction (issue categories, tags, wiki success/failure, intake gaps, frequency/impact, resolution status, email forgotten/misspelled) are noted; tables use parser-derived data where available.
---
## 1. Volume and scope
| Metric | Value |
|--------|--------|
| Total tickets | 722 |
| Transcripts with parse errors | 0 |
| Tickets with “Ticket closed” / “Transcript saving” in payload | 722 (100%) |
| Tickets with claimed channel name (staff claimed) | 671 (93%) |
| Tickets with escalation mentioned in text | 1 |
**Narrative:** The Drive2 batch is fully parseable. Virtually all tickets show a close/saving event; 93% have a claimed channel, indicating most tickets were claimed by staff before closure. Escalations are rare in this set.
---
## 2. Game detection and game_or_server (heuristic)
From decoded form + messages: text buffer scanned for canonical game names and aliases (Part 1 Analysis §5). `game_or_server` routing buckets would require Mode A (Valheim | Rust main | MC modded | MC vanilla | Other).
| game_detected (heuristic) | Count | % |
|---------------------------|-------|---|
| Project Zomboid | 257 | 35.6 |
| Minecraft | 179 | 24.8 |
| Satisfactory | 79 | 10.9 |
| Palworld | 46 | 6.4 |
| Not Mentioned | 45 | 6.2 |
| Enshrouded | 18 | 2.5 |
| ARK: Survival Evolved | 18 | 2.5 |
| 7 Days to Die | 17 | 2.4 |
| Valheim | 14 | 1.9 |
| DayZ | 10 | 1.4 |
| FiveM | 8 | 1.1 |
| Core Keeper | 6 | 0.8 |
| Vintage Story | 5 | 0.7 |
| Rust | 5 | 0.7 |
| Factorio | 5 | 0.7 |
| V Rising | 3 | 0.4 |
| ECO | 3 | 0.4 |
| Necesse | 4 | 0.6 |
**Narrative:** Project Zomboid and Minecraft dominate; Satisfactory and Palworld are next. About 6% have no game detected from text. Full `game_or_server` (MC modded vs vanilla, Rust main, etc.) needs perticket Mode A extraction.
---
## 3. Issue categories and tags
**Issue categories** (Availability, Connectivity, Billing, Data/saves, Configuration/mods) and **TICKET_TAGS** (Server Down, Stuck Restarting, Cant Connect, Server Lag, Billing, Refund Request, Mod Help, Backup Restore, World/Save, Server Config) require Mode A extraction from each transcript. No aggregate table is computed from the batch parser.
**Recommendation:** Run Mode A on all transcripts (or a sample), then aggregate `issue_types` and suggested tags into counts and top tags per game.
---
## 4. Message count and conversation shape
| Messages (header) | Number of tickets |
|-------------------|--------------------|
| 36 | 151 |
| 710 | 128 |
| 1115 | 95 |
| 1622 | 88 |
| 2335 | 78 |
| 3660 | 45 |
| 61+ | 137 |
Summary stats (from header): Min 3, max 356 messages per ticket; majority in the 322 range. Backandforth turns, duration, and “staff asked for more info repeatedly” require Mode A.
**Narrative:** Conversation length is skewed toward short (310 messages) and mid (1135); a smaller set of tickets are long (60+ messages), likely complex or multi-step resolutions.
---
## 5. Attachments (saved / skipped)
| Attachments saved | Number of tickets |
|-------------------|-------------------|
| 0 | 325 |
| 1 | 169 |
| 2 | 81 |
| 3 | 55 |
| 4 | 30 |
| 5+ | 62 |
| Attachments skipped | Number of tickets |
|---------------------|-------------------|
| 0 | 695 |
| 1 | 16 |
| 2 | 6 |
| 3 | 3 |
| 4 | 2 |
**Narrative:** About 45% of tickets have at least one attachment saved; most have none skipped. Skipped reasons and mentions_screenshots/clips/logs require Mode A.
---
## 6. Staff involvement (from payload)
Staff identified by Broccolini support user IDs (Part 1 Analysis §10.1) appearing in message payloads.
| Staff involved (count per ticket) | Number of tickets |
|----------------------------------|-------------------|
| 0 | 152 |
| 1 | 545 |
| 2 | 25 |
**Narrative:** Most tickets have exactly one staff member in the payload; 152 have no staff ID in messages (e.g. Ticket Toolonly or unclaimed). “Tickets claimed per member” and firstresponse time need claim/unclaim message parsing (channel name gives claim attribution; full workload per member needs Mode A or claim-event parsing).
---
## 7. User count (participants per ticket)
| User count (header) | Number of tickets |
|--------------------|-------------------|
| 2 | 70 |
| 3 | 582 |
| 4 | 44 |
| 5 | 8 |
| 6 | 2 |
**Narrative:** Most tickets have 3 participants (requester + 1 staff + Ticket Tool); 4+ participants suggest multi-staff or extra users in thread.
---
## 8. Wiki usage and wikilinked outcomes
**wiki_articles_posted**, **wiki_solved_issue** (true / false / unclear), and stafflinked outcomes (user_wanted_broccolini_to_do_it, user_wanted_broccolini_but_walkthrough) require Mode A extraction. No aggregate table from the batch parser.
**Recommendation:** After Mode A, aggregate: (1) tickets where wiki_solved_issue = true / false / unclear; (2) per support member: wiki posts that solved vs did not, “do it for me” vs walkthrough counts.
---
## 9. Email analytics
Parser did not detect “Account Email” + email in the same decoded block in this run. **Email analytics** (email_forgotten, email_misspelled, email_didnt_link, email_corrected) require Mode A extraction from form embeds and message text.
---
## 10. Frequency / impact distributions
**frequency** (once | sometimes | every_time | unclear) and **impact** (minor | moderate | severe | blocked | unclear) require inference from transcript wording (Mode A). No aggregate table from the batch parser.
---
## 11. Resolution patterns
From parser: all 722 tickets contain “Ticket closed” or “Transcript saving” in the payload. **status** (resolved | unresolved | escalated | unclear) and **relied_on** (logs | mod_updates | staff_action | other) require Mode A. One ticket mentions escalation in text.
**Narrative:** All transcripts represent closed/saved tickets; resolution outcome and what resolution relied on need perticket extraction.
---
## 12. Intake gaps
Perticket intake_gaps (account_contact, issue_type, reproduction, environment, attachments, priority, rules) each as complete | partial | missing require Mode A. No aggregate table from the batch parser.
**Recommendation:** After Mode A, report % complete / partial / missing per dimension to target form and template improvements.
---
## 13. Recurring analytics (Broccolini support section)
From the batch parser we have:
- **Tickets per game_detected (heuristic):** see §2.
- **Claimed channel share:** 671/722 (93%).
- **Staff involved count per ticket:** see §6.
**Require Mode A or claim parsing:**
- Tickets claimed per member (from claim/unclaim messages or channel name).
- First response time, reopens, escalations.
- Tag distribution, repeat customers, sentiment toward staff.
- Wikilinked outcomes per member (§9.2).
---
## 14. How to reproduce and extend
1. **Run batch parser (this reports source):**
```bash
python3 scripts/batch_transcript_analytics.py "Discord Ticket Transcripts/Drive2"
```
For a subfolder or Drive:
```bash
python3 scripts/batch_transcript_analytics.py "Discord Ticket Transcripts/Drive"
```
2. **Full Mode C tables:** Run Mode A extraction on each transcript (or a sample), collect JSON, then aggregate by issue categories, tags, game_or_server, wiki_solved_issue, intake_gaps, frequency/impact, resolution status, and email analytics. Use Part 1 Analysis and `docs/TICKET-ANALYTICS-SCHEMA-PROMPTING.md` as the schema source of truth.

View File

View File

@@ -0,0 +1,108 @@
# Part 2 — Deep batch analytics report (Mode C, secondorder)
**Source:** `Discord Ticket Transcripts/Drive2/` (722 transcripts).
**Input:** Parser-derived data from transcript HTML + decoded base64 payloads (timestamps, claim owner, game heuristic, attachments, staff involvement).
**Guides:** Part 1 Analysis (schemas), Part 2 Analysis (batch dimensions), Part 1 Batch Analytics Report (structure).
**Tool:** `scripts/batch_transcript_analytics.py` (extended with timestamps and claim_owner).
This report focuses on **patterns, bottlenecks, and recommendations** rather than raw counts. Dimensions that require **Mode A perticket JSON** (issue_types, tags, resolution.status, intake_gaps, wiki_solved_issue, frequency/impact, discord_handle) are called out; tables use parser-derived data where available and note where full analysis needs extraction.
---
## 1. Trends and anomalies over time
| Period (UTC month) | Tickets | % of total |
|-------------------|--------|------------|
| 2025-09 | 7 | 1.0 |
| 2025-10 | 288 | 39.9 |
| 2025-11 | 427 | 59.1 |
**Game mix over time (parser heuristic; full trend needs Mode A):**
From Part 1 report, top games in the batch are Project Zomboid (35.6%), Minecraft (24.8%), Satisfactory (10.9%), Palworld (6.4%). No permonth game breakdown was computed; **recommend** running Mode A and grouping by `first_message_ts` (or `time.created`) and `issue.game_detected` to detect spikes (e.g. Palworld postlaunch, MC modpack updates).
**Narrative:** Volume rises sharply from September to November (7 → 288 → 427). The jump suggests either growth in support load or a change in transcript export coverage. To interpret seasonality, spikes by game, or resolution.status over time, add Mode A extraction and bucket by week/month; then cross-tab with issue_types, game_detected, and tags to spot clusters (e.g. connectivity spikes for a specific game or platform).
---
## 2. Where tickets get stuck (bottlenecks)
| Bottleneck | Count | % of 722 | Notes |
|------------|-------|----------|--------|
| No staff ID in message payload | 152 | 21.1 | Ticket Toolonly or unclaimed; no Broccolini author in payload. |
| No attachments saved | 325 | 45.0 | Diagnostic quality unknown; mentions_logs/screenshots need Mode A. |
| Unclaimed (no claim_owner in channel name) | 187 | 25.9 | Channel not in *staff*-claimed-* format. |
| Long threads (60+ messages) | 55 | 7.6 | Likely complex or multi-step; good candidates for playbook review. |
| Escalation mentioned in text | 1 | 0.1 | Rare in this set. |
**Narrative:** About one in five tickets have no staff author in the payload (either unclaimed or structural); a quarter show no claim in the channel name. Nearly half have zero attachments saved—correlating with resolution.status and intake_gaps.attachments (Mode A) would show whether missing diagnostics lengthen resolution or increase unresolved/escalated rates. Long threads (60+ messages) are a small but important slice; recommend tagging these and reviewing for missing reproduction steps, repeated “need more info,” or unclear priority so forms and macros can be improved.
---
## 3. Wiki and selfservice leverage
| Dimension | Parser-derived | Mode A required |
|-----------|----------------|-----------------|
| Wiki articles posted | — | `staff_and_wiki.wiki_articles_posted` |
| wiki_solved_issue (true/false/unclear) | — | Per ticket, then % by game_detected, issue_types |
| “Do it for me” vs walkthrough | — | `user_wanted_broccolini_to_do_it`, `user_wanted_broccolini_but_walkthrough` per member |
**Narrative:** Wiki usage and outcomes cannot be computed from the current parser. With Mode A JSON, build: (1) **top wiki slugs** by usage and by game_detected/issue_types; (2) **wiki_solved_issue** rate per article and per game/issue slice; (3) permember counts for “do it for me” vs “walkthrough.” Use that to find topics where users often want staff to “do it” and either add or improve wiki steps and macros so more tickets can be resolved via selfservice. Recommend a short table per major wiki category (e.g. connectivity, backups, mods) with usage, success rate, and suggested new or updated pages.
---
## 4. Stafflevel patterns and opportunities
| Member (claim_owner from channel) | Tickets claimed | % of claimed | Notes |
|-----------------------------------|-----------------|--------------|--------|
| indifferentchicken | 407 | 76.0 | Majority of claimed tickets in this batch. |
| indifferentketchup | 128 | 24.0 | Second by volume. |
| indifferentbroccoli, indifferentcat, indifferentstone | 0 (in channel name) | — | No claims in this export with their channel prefix. |
**Staff involved in payload (any message):** 545 tickets have exactly one staff ID in payload, 25 have two, 152 have zero (see §2). Claim count above is from channel name only; staff_involved from payload can include secondary participants.
**Narrative:** indifferentchicken carries most of the claimed load (76%); indifferentketchup handles the rest. The other three Broccolini members do not appear as claim_owner in this channel-name set—they may be in other panels, or claims may use different naming. To get full workload, resolution rate, escalation rate, first-response time, and wiki success per member, use Mode A plus claim/unclaim message parsing. **Recommendations:** (1) Cross-check with Discord/Ticket Tool to ensure all support members claim patterns are visible; (2) use Mode A to compute tickets resolved per member and escalation rate per member; (3) if one member remains disproportionately loaded, consider routing or documentation so common game/issue combinations can be handled by others or by wiki/macros.
---
## 5. Repeat customers and recurring problems
| Dimension | Parser-derived | Mode A required |
|-----------|----------------|-----------------|
| Tickets per requester | — | `account.discord_handle` or user ID; group by requester, count tickets. |
| Repeat customers (2+ tickets) | — | Same; filter count ≥ 2. |
| Cluster by game, issue_types, mods, errors | — | Per-ticket issue_types, reproduction.error_messages, environment.mods, game_detected. |
**Narrative:** Requester identity is not in the parser output; repeat customers and recurring problems need Mode A (`account.discord_handle`, issue, reproduction, environment). With Mode A: (1) list requesters with 2+ tickets and their ticket IDs; (2) for each, cluster by game_detected, issue_types, mods, and error_messages to see if the same problem recurs or different games/issues; (3) suggest proactive actions—e.g. new guides for frequent error clusters, default configs or form questions for common mod/platform combos, or targeted announcements for repeat customers with the same issue type.
---
## 6. Design and process changes (recommendations)
| Area | Finding (from this report) | Recommended change |
|------|----------------------------|--------------------|
| **Forms / bot questions** | 45% of tickets have 0 attachments saved; intake_gaps unknown without Mode A. | After Mode A: if intake_gaps.reproduction or intake_gaps.attachments are often missing for high-impact or long-thread tickets, add required reproduction steps and “attach logs/screenshot” for selected issue types or tags. |
| **Wiki coverage** | Wiki usage/success by game and issue not computable from parser. | After Mode A: identify top issue_types and game_detected with low wiki_solved_issue or high “do it for me”; add or update wiki pages and link them in macros. |
| **Tagging / routing** | issue_types and tags require Mode A. | After Mode A: define routing rules for high-impact or high-volume game+issue combinations (e.g. Palworld connectivity → specific wiki + optional escalation path). |
| **Staff playbooks / macros** | Long threads (60+ messages) and 152 tickets with no staff in payload suggest some tickets stall or lack clear handoff. | (1) Add a macro or playbook for “request logs/screenshots” when reproduction is missing; (2) encourage claiming so every ticket has an owner; (3) document common flows for top game+issue pairs (e.g. PZ config, MC mods) so response time and consistency improve. |
| **Lifecycle visibility** | No time to first response or time to resolution without Mode A timestamps. | Run Mode A; extract time.created, time.claimed, time.closed (or first/last message timestamps) to compute response and resolution times and re-opens, then tune SLAs and staffing. |
**Narrative:** This batch surfaces **claim imbalance** (one member holding most claims), **missing diagnostics** (many tickets with no attachments saved), and **unclaimed or no-staff-in-payload** tickets. Full intake_gaps, resolution.status, and wiki outcomes depend on Mode A. Prioritise: (1) Mode A on a representative sample (or full set) to fill Tables 3, 5, and Part 1 report gaps; (2) form and bot changes for reproduction and attachments where gaps correlate with poor outcomes; (3) wiki and macro improvements where “do it for me” is high; (4) routing and playbooks for high-volume game+issue combinations and long-thread tickets.
---
## 7. How to reproduce and extend
1. **Run the extended batch parser (timestamps + claim_owner):**
```bash
python3 scripts/batch_transcript_analytics.py "Discord Ticket Transcripts/Drive2"
```
Use `Discord Ticket Transcripts/Drive/` for the larger set.
2. **Add Mode A extraction** for full Part 2 analysis: run Mode A on each transcript (or sample), collect JSON, then:
- Bucket by `time.created` or first_message_ts for **trends** (issue_types, game_detected, resolution.status by week/month).
- Correlate **intake_gaps** with resolution time, unresolved/escalated rate, and back_and_forth_turns for **bottlenecks**.
- Aggregate **wiki_solved_issue**, wiki_articles_posted, and “do it” vs walkthrough for **wiki & selfservice**.
- Use claim/unclaim + resolution + wiki for **staff profiles** (workload, response time, wiki success, escalations).
- Group by **account.discord_handle** for **repeat customers** and cluster by game/issue/mod/error.
3. **Schema and dimensions:** Part 1 Analysis and `docs/TICKET-ANALYTICS-SCHEMA-PROMPTING.md` are the source of truth for fields; Part 2 Analysis defines the batch dimensions (§§212).

View File

@@ -0,0 +1,250 @@
# Critical Files & How Broccolini Bot Works
This document identifies the **most critical files** for understanding the repo and gives a **thorough explanation** of how the bot works end-to-end.
---
## Most Critical Files (Read These First)
These are the files that give someone the fastest path to understanding the repo. Read in roughly this order.
### 1. [**README.md**](../README.md) (repo root)
- **Why:** Single source of truth for features, architecture diagram, config, commands, and troubleshooting.
- **What you get:** High-level picture, env vars, Discord commands, tag/panel systems, database schema summary, and links to other docs.
### 2. [**broccolini-discord.js**](../broccolini-discord.js) (entry point)
- **Why:** Where the bot starts and where all major pieces are wired together.
- **What you get:** Discord client setup, `interactionCreate` routing (buttons → commands → modals → context menus → autocomplete), `messageCreate` → Gmail reply handler, and `ready` logic: MongoDB connect, command registration, Gmail poll start, and background job intervals (auto-close, reminders, auto-unclaim). Also mounts the Express healthcheck and optional bOSScord API.
### 3. [**config.js**](../config.js)
- **Why:** All runtime configuration comes from here (env + defaults).
- **What you get:** Single `CONFIG` object: Discord IDs, Gmail/MongoDB settings, automation toggles, message templates, button labels, priority/game lists, and guild-specific options. Test env is supported via `ENV_FILE=.env.test`.
### 4. [**models.js**](../models.js) (Broccolini Bot section, ~line 793+)
- **Why:** Data model defines what the bot persists and how tickets are represented.
- **What you get:** Mongoose schemas for **Ticket** (gmailThreadId, discordThreadId, senderEmail, status, claimedBy, priority, escalation, etc.), **TicketCounter**, **Transcript**, **Tag**, **CloseRequest**, **GuildSettings**. Earlier in the file: **User**, **Host**, and other game/hosting models used by `/accountinfo` and external integrations.
### 5. [**gmail-poll.js**](../gmail-poll.js)
- **Why:** This is the “email → Discord” bridge: how support emails become ticket channels.
- **What you get:** `poll(client)` runs every 30s: lists unread primary inbox, skips messages from own address, parses From/Subject/body, strips quotes/footers, detects game from `GAME_LIST`, checks ticket limits and rate limits, gets next ticket number, creates Discord channel (or thread) and embed with Claim/Close buttons, saves Ticket + optional Transcript in MongoDB, marks email read. Overflow categories when a category hits 50 channels.
### 6. [**handlers/messages.js**](../handlers/messages.js)
- **Why:** This is the “Discord → email” bridge: staff messages in a ticket become Gmail replies.
- **What you get:** `handleDiscordReply(message)`: ignores bots and non-ticket channels; looks up Ticket by `discordThreadId`; skips if ticket is Discord-origin (`gmailThreadId.startsWith('discord-')`); for email tickets, gets Gmail thread, finds last customer message, builds reply with staff name and content, calls `sendGmailReply`, and updates `lastActivity`.
### 7. [**services/gmail.js**](../services/gmail.js)
- **Why:** All Gmail API usage and outbound email logic.
- **What you get:** OAuth2 client via `getGmailClient()`; `sendGmailReply()` (threaded reply with HTML, In-Reply-To/References); `sendTicketClosedEmail()` for closure notifications; optional `sendTicketNotificationEmail()` (e.g. priority high). Raw MIME construction and `users.messages.send`.
### 8. [**services/tickets.js**](../services/tickets.js)
- **Why:** Core ticket lifecycle and Discord channel/thread creation.
- **What you get:** Ticket numbers (`getNextTicketNumber`), channel naming and Discord rate limit handling (2 renames per 10 min), ticket limits and overflow category selection, rate limit for ticket creation per user, `createEmailTicketAsThread` / `createDiscordTicketAsThread`, auto-close/reminder/auto-unclaim jobs, and helpers like `updateTicketActivity`, `canRename`, `makeTicketName`.
### 9. [**handlers/buttons.js**](../handlers/buttons.js)
- **Why:** Every button and ticket modal goes through here.
- **What you get:** “Open Ticket” panel → modal (email, game, description); email routing buttons (thread vs category); Claim / Unclaim / Close (including close confirmation flow); priority and tag selects; escalation/deescalation; and `handleTicketModal` for creating a ticket from the panel. Integrates with `commands.js` for escalation and with `tickets.js`/`gmail.js` for close and notifications.
### 10. [**handlers/commands.js**](../handlers/commands.js)
- **Why:** All slash commands and context menus are implemented here.
- **What you get:** Staff check (`requireStaffRole`), then routing for `/claim`, `/unclaim`, `/close`, `/priority`, `/topic`, `/escalate`, `/deescalate`, `/add`, `/remove`, `/transfer`, `/move`, `/tag`, `/response *`, `/panel`, `/email-routing`, `/accountinfo`, `/search`, `/stats`, `/backup`, `/export`, `/help`, and context menu “Create Ticket From Message”. Uses `tickets.js`, `guildSettings`, analytics, and accountinfo/setup handlers.
### 11. [**commands/register.js**](../commands/register.js)
- **Why:** Defines and registers every slash command and context menu with Discord.
- **What you get:** Full list of command names, options, permissions, and context types. Run at startup so Discord has the latest command definitions.
### 12. [**db-connection.js**](../db-connection.js)
- **Why:** MongoDB is required; this is the only place that connects and loads models.
- **What you get:** `connectMongoDB(uri)`, requires `models.js`, and wires `error` / `disconnected` / `reconnected` for resilience.
### 13. [**utils.js**](../utils.js)
- **Why:** Shared parsing and formatting used by Gmail poll, Gmail service, and commands.
- **What you get:** `getCleanBody`, `extractRawEmail`, `stripEmailQuotes`, `stripMobileFooter`, `detectGame` (from subject/body vs `GAME_LIST`), `replaceVariables` for tag/response templates (`{ticket.user}`, `{staff.name}`, etc.), `getPriorityEmoji`, `getFormattedDate`, `escapeHtml`, `htmlToTextWithBlocks`.
### 14. [**utils/ticketComponents.js**](../utils/ticketComponents.js)
- **Why:** Central place for Claim/Unclaim/Close (and related) button rows and embeds.
- **What you get:** `getTicketActionRow()` and related builders so ticket channels and panels show consistent buttons and styling.
### Supporting but still important
- [**services/guildSettings.js**](../services/guildSettings.js) Guild-specific settings (e.g. email routing: thread vs category), cached and persisted in MongoDB.
- [**services/debugLog.js**](../services/debugLog.js) Structured logging and optional Discord debugging channel.
- [**handlers/accountinfo.js**](../handlers/accountinfo.js) `/accountinfo` and lookup logic (website user / Discord link).
- [**handlers/analytics.js**](../handlers/analytics.js) In-memory interaction/error tracking and `/stats`.
- [**handlers/setup.js**](../handlers/setup.js) Guild setup flow (buttons/modals/selects).
- [**game-options.json**](../game-options.json) Game list used for dropdowns/options.
- [**QUICKSTART.md**](QUICKSTART.md) Short path to first reply, panel, tags, priority.
- [**ENV_AND_SECURITY.md**](ENV_AND_SECURITY.md) Test env workflow and security/agent rules.
- [**PROJECT_STRUCTURE.md**](PROJECT_STRUCTURE.md) File/directory layout reference.
---
## How the Bot Works (End-to-End)
### Overview
Broccolini Bot is a **Node.js support-ticket bot** that connects **Gmail**, **Discord**, and **MongoDB**. Incoming support emails become Discord ticket channels (or threads); staff reply in Discord and their messages are sent back to the customer via Gmail. Tickets can also be created from Discord via a panel (no email). All ticket state is stored in MongoDB.
---
### Startup sequence
1. **Load config**
[config.js](../config.js) loads `.env` (or `ENV_FILE`), runs dotenv-expand, and exports `CONFIG`.
2. **Validate env**
[broccolini-discord.js](../broccolini-discord.js) checks required vars (e.g. `DISCORD_TOKEN`, `TICKET_CATEGORY_ID`, Gmail OAuth). Missing required ones cause exit.
3. **Create Discord client**
Client is created with intents: Guilds, GuildMessages, MessageContent, GuildMembers; Partials.Channel for ticket channels that might not be in cache.
4. **Register event handlers**
- **`interactionCreate`** Buttons (accountinfo, setup, ticket actions), modals (setup, ticket creation), slash commands, context menus, autocomplete. Order matters: prefix checks (e.g. accountinfo, setup) run before generic button/command handlers.
- **`messageCreate`** `handleDiscordReply`: Discord → Gmail for staff messages in ticket channels.
5. **`ready`**
- Connect MongoDB via [db-connection.js](../db-connection.js) (and load [models.js](../models.js)).
- Set debug log client and bOSScord API client.
- If `BOSSCORD_API_KEY` is set, mount `/api` routes (e.g. bOSScord).
- Call `registerCommands()` so slash commands and context menus are registered for the guild.
- Start Gmail poll: `poll(client)` immediately and then `setInterval(..., 30000)`.
- If enabled: start hourly auto-close, 30-minute reminders, hourly auto-unclaim.
- Express server is already created; it listens on `CONFIG.PORT` and serves `GET /``"Active"` for healthchecks.
6. **No Gmail/MongoDB**
If `MONGODB_URI` is missing, the bot exits in `ready`. Gmail credentials are validated at startup but polling can fail later if tokens are bad.
---
### Email → Discord (new ticket from email)
1. **Gmail poll** ([gmail-poll.js](../gmail-poll.js))
- Every 30s, `poll(client)` uses Gmail API `users.messages.list` with `is:unread category:primary`.
- For each message, fetches full message, parses From/Subject/body. Skips if From is the support address (and marks it read).
- Extracts sender email and name; cleans body (strip reply quotes, mobile footers) via [utils.js](../utils.js).
- Detects game from subject/body using `GAME_LIST` (`utils.detectGame`).
- Checks global and per-category ticket limits and per-user rate limit (`services/tickets.js`).
- Gets next ticket number per sender from `TicketCounter` (`getNextTicketNumber`).
- Decides where to create the ticket: **thread** vs **category channel** from `getEmailRouting()` (guild setting, can be set via `/email-routing`).
- Creates the Discord channel or thread, posts an embed (subject, sender, game, ticket number) and action row (Claim, Close) from `ticketComponents.js`.
- Saves a **Ticket** in MongoDB: `gmailThreadId`, `discordThreadId` (channel or thread id), `senderEmail`, `subject`, `ticketNumber`, game, status `open`, etc. Optionally creates **Transcript** placeholder.
- Marks the Gmail message read so it is not processed again.
2. **Overflow**
Discord allows 50 channels per category. If the main ticket category is full, the bot uses `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` (and similar for Discord-origin tickets) to pick another category.
---
### Discord → Email (staff reply)
1. **Message event**
When a message is sent in a channel, `handleDiscordReply` in [handlers/messages.js](../handlers/messages.js) runs.
2. **Filter**
Ignores bots and interactions. Finds a Ticket by `discordThreadId === channel.id`. If none, or if `gmailThreadId.startsWith('discord-')`, does nothing (Discord-origin tickets have no Gmail thread).
3. **Build reply**
Uses Gmail API `users.threads.get` to get the thread. Finds the last message from the customer (not from support). Reads To/Reply-To, Subject, Message-ID.
4. **Send**
`sendGmailReply(threadId, content, recipientEmail, subject, discordUser, messageId)` in [services/gmail.js](../services/gmail.js) builds HTML (staff name, content, logo/signature), sets In-Reply-To/References for threading, and calls `users.messages.send` with `threadId` so the reply stays in the same Gmail thread.
5. **Activity**
`updateTicketActivity(ticket.gmailThreadId)` updates the tickets `lastActivity` for auto-close and reminder logic.
---
### Ticket creation from Discord (panel)
1. **Panel**
Staff runs `/panel` (optionally with channel, type, title, description). The bot sends a message with an “Open Ticket” button (and optional styling).
2. **Button click**
User clicks the button. [handlers/buttons.js](../handlers/buttons.js) shows a modal with: Account Email, Game, Description (and possibly priority).
3. **Modal submit**
`handleTicketModal` runs. Validates and applies rate limit (`checkTicketCreationRateLimit`). Creates a Ticket in MongoDB with `gmailThreadId = 'discord-' + ...` (no real Gmail thread). Gets next ticket number (by email or Discord user). Creates a Discord channel or thread (depending on panel type and guild settings), posts welcome embed and Claim/Close buttons, saves `discordThreadId` and other fields. No Gmail is involved for this ticket; `handleDiscordReply` explicitly skips when `gmailThreadId.startsWith('discord-')`.
---
### Claim / Unclaim / Close
- **Claim** (button or `/claim`)
Ticket is updated with `claimedBy` (user id or name). Channel may be renamed (respecting Discords 2 renames per 10 min). Claimed message is posted (template from CONFIG).
- **Unclaim** (button or `/unclaim`)
`claimedBy` is cleared; channel rename and message as above.
- **Close** (button or `/close`)
Close button often triggers a confirmation (e.g. “Are you sure?” with Confirm/Cancel). On confirm (or `/force-close`): build transcript of the channel, post it to the transcript channel, send closure email for **email tickets** via `sendTicketClosedEmail`, delete the Discord channel/thread, set ticket status to `closed` and clean up CloseRequest. Discord-origin tickets get no Gmail closure email.
---
### Automation (background jobs)
- **Auto-close**
If `AUTO_CLOSE_ENABLED`, `checkAutoClose` runs hourly. Finds open tickets whose `lastActivity` is older than `AUTO_CLOSE_AFTER_HOURS`. For each, same flow as manual close (transcript, closure email if email ticket, delete channel, update ticket).
- **Reminders**
If `REMINDER_ENABLED`, `checkReminders` runs every 30 minutes. Finds open tickets inactive longer than `REMINDER_AFTER_HOURS` and not yet reminded. Sends reminder message to the channel and sets `reminderSent`.
- **Auto-unclaim**
If `AUTO_UNCLAIM_ENABLED`, `checkAutoUnclaim` runs hourly. Clears `claimedBy` on tickets that have been inactive for `AUTO_UNCLAIM_AFTER_HOURS`.
All of these use the Ticket models `lastActivity` (and optional `reminderSent`) and live in [services/tickets.js](../services/tickets.js).
---
### Tags and saved responses
- **Tags**
`/tag` sets a ticket category (e.g. Server Down, Billing). Stored on the ticket and can be used in naming or display. Tag options come from `CONFIG.TICKET_TAGS` (from config / env).
- **Saved responses**
Stored in MongoDB (**Tag** collection for response name/content). `/response create|edit|delete|list|send`. When sending, `utils.replaceVariables` substitutes `{ticket.user}`, `{staff.name}`, `{date}`, etc. Autocomplete for response names is provided in `handlers/commands.js`.
---
### Priority and escalation
- **Priority**
If `PRIORITY_ENABLED`, tickets have low/normal/medium/high. Stored on Ticket; embeds and channel naming can show priority emoji. Optional: send email when set to high (`sendTicketNotificationEmail`).
- **Escalation**
`/escalate` and `/deescalate` (or buttons) change `escalationTier` (0 → 1 → 2) and move the channel to escalation categories (e.g. `EMAIL_ESCALATED_CATEGORY_ID`, `DISCORD_ESCALATED2_CHANNEL_ID`). Channel rename and “escalated” message are posted. Optional escalation notification email.
---
### Account info and other commands
- **`/accountinfo`**
Looks up a user by email or Discord ID (uses **User** and related models from `models.js`). Can post results to a dedicated channel; handler in `handlers/accountinfo.js`.
- **`/email-routing`**
Toggles where new **email** tickets are created: thread under a parent channel or channel in a category. Value is stored per guild in **GuildSettings** and read by [gmail-poll.js](../gmail-poll.js) via `getEmailRouting()`.
- **`/panel`**
Sends a message with an “Open Ticket” button; panel type (thread vs channel) and target channel are options.
- **`/search`, `/backup`, `/export`**
Query or export tickets (by status, limit, etc.) and post results (e.g. to backup channel).
- **`/stats`**
Returns in-memory analytics (interactions, errors) from `handlers/analytics.js`.
---
### Data flow summary
- **Gmail → Discord:** [gmail-poll.js](../gmail-poll.js) (poll) → [services/gmail.js](../services/gmail.js) (read), [services/tickets.js](../services/tickets.js) (create channel + Ticket), [utils.js](../utils.js) (parse/detect game).
- **Discord → Gmail:** [handlers/messages.js](../handlers/messages.js) → [services/gmail.js](../services/gmail.js) (`sendGmailReply`), [services/tickets.js](../services/tickets.js) (`updateTicketActivity`).
- **Discord-only ticket:** [handlers/buttons.js](../handlers/buttons.js) (modal) → [services/tickets.js](../services/tickets.js) (create channel + Ticket with `gmailThreadId: 'discord-...'`).
- **All interactions:** [broccolini-discord.js](../broccolini-discord.js) (routing) → [handlers/buttons.js](../handlers/buttons.js) or [handlers/commands.js](../handlers/commands.js) → [services/tickets.js](../services/tickets.js), [services/guildSettings.js](../services/guildSettings.js), [services/gmail.js](../services/gmail.js), and [models.js](../models.js) (Mongoose).
---
### Dependencies (high level)
- **discord.js** Client, channels, embeds, buttons, modals, slash commands, context menus.
- **googleapis** Gmail API (OAuth2, list/get messages, send, threads).
- **mongoose** MongoDB connection and Broccolini Bot + game/hosting models.
- **express** Healthcheck server and bOSScord API mount.
For full setup, config, and troubleshooting, use [README.md](../README.md), [QUICKSTART.md](QUICKSTART.md), and [ENV_AND_SECURITY.md](ENV_AND_SECURITY.md).

13
docs/reference/Untitled Normal file
View File

@@ -0,0 +1,13 @@
## 13. Prompting: deep analytic experience
**Mode A — single transcript, JSON only**
> “For this single transcript, use **Mode A: Extraction** and respond only with a single JSON object that follows the toplevel structure in the Singletranscript JSON response schema and the field definitions in Transcript analytics schemas (perfield definitions). Follow the General extraction rules. Output only valid JSON.”
**Mode B — single transcript, narrative**
> “Using the schemas in this document (support issue categories, ticket tags, wiki categories and slug patterns, gamespecific topics, game detection, Broccolini team IDs, wiki suggestion & outcome analytics), summarize this ticket transcript. Include account & contact, issue (with game_detected and game_or_server), reproduction, environment, priority & impact, rules/abuse if applicable, suggested wiki slugs, and any staff mentions/requests/sentiment. Note whether any wiki article appeared to solve or not solve the issue, and whether the user wanted Broccolini to do it or was walked through doing it themselves.”
**Mode C — batch analytics**
> “Using **Mode C: Batch analytics** over transcripts in [path] or a list of JSON objects from Mode A, compute perticket and aggregate analytics from this document: issue categories, tags, game_detected and game_or_server distributions, wiki usage and success/failure, staff involvement and wikilinked outcomes, email analytics, frequency/impact distributions, resolution patterns, intake gaps, and all recurring analytics in the Broccolini support section. Output tables and a concise narrative per major dimension.”

View File

@@ -0,0 +1,97 @@
# Game list (Broccolini Bot schema)
Canonical list of games and their **display name**, **key** (snake_case), and **aliases**. Used by `config.js` (`GAME_LIST`, `GAME_ALIASES`, `GAME_NAME_TO_KEY`) and `game-options.json`.
## GAME_LIST (display names, comma-separated)
Use this value for the `GAME_LIST` env var:
```
7 Days to Die, Abiotic Factor, ARK: Survival Evolved, Conan Exiles, Core Keeper, Counter-Strike 2, DayZ, ECO, Enshrouded, Factorio, FiveM, The Front, Garry's Mod, Hytale, ICARUS, Minecraft, Necesse, Palworld, Project Zomboid, Rust, Satisfactory, Sons of the Forest, Soulmask, Star Rupture, Terraria, Valheim, VEIN, Vintage Story, Voyagers of Nera, V Rising
```
## Table (display name, key, aliases)
| Display name | Key | Aliases |
|--------------|-----|--------|
| 7 Days to Die | `7_days_to_die` | `7D2D`, `7 days` |
| Abiotic Factor | `abiotic_factor` | — |
| ARK: Survival Evolved | `ark_survival_evolved` | `Ark` |
| Conan Exiles | `conan_exiles` | — |
| Core Keeper | `core_keeper` | — |
| Counter-Strike 2 | `counter_strike_2` | `CS2` |
| DayZ | `dayz` | — |
| ECO | `eco` | — |
| Enshrouded | `enshrouded` | — |
| Factorio | `factorio` | — |
| FiveM | `fivem` | — |
| The Front | `the_front` | — |
| Garry's Mod | `garrys_mod` | — |
| Hytale | `hytale` | — |
| ICARUS | `icarus` | — |
| Minecraft | `minecraft` | `MC` |
| Necesse | `necesse` | — |
| Palworld | `palworld` | — |
| Project Zomboid | `project_zomboid` | `PZ`, `zomboid` |
| Rust | `rust` | — |
| Satisfactory | `satisfactory` | — |
| Sons of the Forest | `sons_of_the_forest` | `SOTF` |
| Soulmask | `soulmask` | — |
| Star Rupture | `star_rupture` | — |
| Terraria | `terraria` | — |
| Valheim | `valheim` | — |
| VEIN | `vein` | — |
| Vintage Story | `vintage_story` | — |
| Voyagers of Nera | `voyagers_of_nera` | — |
| V Rising | `v_rising` | — |
---
## Matching rules (e.g. 7D2D → 7 Days to Die)
Game detection lives in **`utils.js`** (`detectGame(subject, body)`). It only looks at the **combined subject + body** (lowercased). Matching is **case-insensitive** and uses **word boundaries**.
### 1. Full names first
- **Source:** `GAME_NAMES` (from env `GAME_LIST`, comma-separated, trimmed).
- **How:** For each game name, the code builds a regex: `\b` + escaped name + `\b`, with flag `i`.
- So the **exact display name** must appear as **whole words**.
Examples: “7 days to die” matches → `7 Days to Die`; “zomboid” alone does *not* match “Project Zomboid” (would need “project zomboid” as words).
### 2. Aliases second
- **Source:** `GAME_ALIASES` in `config.js` (alias → full display name).
- **How:** For each alias, the **alias** is lowercased, then the same pattern: `\b` + escaped alias + `\b`, case-insensitive.
- If it matches, the function returns the **full game name** (the value in `GAME_ALIASES`).
- So “7d2d”, “7D2D”, “7 days” match and resolve to **7 Days to Die**; “PZ” / “zomboid” resolve to **Project Zomboid**; “MC” → **Minecraft**; “Ark” → **ARK: Survival Evolved**; “SOTF” → **Sons of the Forest**; “CS2” → **Counter-Strike 2**.
### 3. Word boundaries
- `\b` means “word boundary” (between word and non-word character, or start/end of string).
- So “7d2d” matches “my 7d2d server” or “7D2D” but not “7d2dmod” (no boundary after 7d2d) unless that substring appears as a separate word.
### 4. Order and “first match wins”
- Full names are checked **before** aliases. So if the text contains both a full name and an alias, the full name wins when it matches as whole words.
- First matching game in `GAME_NAMES` or first matching alias in `GAME_ALIASES` wins; no tie-breaking between games.
### 5. No match
- If neither a full name nor an alias matches (with word boundaries), `detectGame` returns **`'Not Mentioned'`**.
### Summary
| Input (in subject/body) | Resolved game |
|-------------------------|----------------|
| 7d2d, 7D2D, 7 days | 7 Days to Die |
| PZ, zomboid | Project Zomboid |
| MC | Minecraft |
| Ark | ARK: Survival Evolved |
| SOTF | Sons of the Forest |
| CS2 | Counter-Strike 2 |
When adding a new game:
1. Add its **display name** to `GAME_LIST` (env) and to `GAME_NAME_TO_KEY` in `config.js`.
2. Add **key → display name** to `game-options.json`.
3. If users might type a shorthand (e.g. 7D2D), add an entry to **`GAME_ALIASES`** in `config.js` mapping that alias to the full display name.

View File

@@ -0,0 +1,60 @@
# Regex detection code and games list
## Regex detection code (utils.js)
```javascript
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const detectGame = (subject, body) => {
const txt = `${subject} ${body}`.toLowerCase();
for (const game of GAME_NAMES) {
const g = game.toLowerCase();
const re = new RegExp(`\\b${escapeRegex(g)}\\b`, 'i');
if (re.test(txt)) return game;
}
for (const [alias, fullName] of Object.entries(GAME_ALIASES)) {
const a = alias.toLowerCase();
const re = new RegExp(`\\b${escapeRegex(a)}\\b`, 'i');
if (re.test(txt)) return fullName;
}
return 'Not Mentioned';
};
```
## Games list
- 7 Days to Die
- Abiotic Factor
- ARK: Survival Evolved
- Conan Exiles
- Core Keeper
- Counter-Strike 2
- DayZ
- ECO
- Enshrouded
- Factorio
- FiveM
- The Front
- Garry's Mod
- Hytale
- ICARUS
- Minecraft
- Necesse
- Palworld
- Project Zomboid
- Rust
- Satisfactory
- Sons of the Forest
- Soulmask
- Star Rupture
- Terraria
- Valheim
- VEIN
- Vintage Story
- Voyagers of Nera
- V Rising

126
docs/setup/1PASSWORD.md Normal file
View File

@@ -0,0 +1,126 @@
# 1Password integration (API keys & tokens)
Use 1Password as the single source of truth for Broccolini Bot secrets. Your `.env` file then holds only **Secret References** (`op://...`), so you never store plaintext tokens on disk.
---
## Setup checklist (extension + CLI)
| Step | What to do |
|------|------------|
| **1. Extension** | In Cursor: **Extensions** (Ctrl+Shift+X) → search **1Password****Install**. *(On WSL/Linux the extension may refuse to install; use the CLI path below instead.)* |
| **2. Choose account** | After installing: Command Palette (Ctrl+Shift+P) → **1Password: Choose account** → sign in and pick a vault. |
| **3. CLI** | Install 1Password CLI: `sudo apt install op` (WSL/Linux) or [install from 1Password](https://developer.1password.com/docs/cli/get-started/). Then run **`eval $(op signin)`** once per terminal (or add to your shell profile). |
| **4. Store secrets** | In 1Password: create an item (e.g. **Broccolini Bot**) and add custom fields for each secret (e.g. `DISCORD_TOKEN`, `MONGODB_URI`, `REFRESH_TOKEN`). Use **Copy reference** on each field. |
| **5. .env with refs** | In `broccolini-bot/.env`: put `KEY=op://Vault/ItemName/FIELD` for each secret (no plaintext). Use **1Password: Get from 1Password** in the editor to insert refs, or paste from step 4. |
| **6. Run bot** | From `broccolini-bot/`: **`npm run start:1p`** (or `op run --env-file=.env -- npm run start`). |
If the extension is not available on your OS, use **steps 36 only** (CLI + Secret References in `.env` + `op run`).
---
## Extensions and Cursor integration
| What | Where | Purpose |
|------|--------|--------|
| **1Password for VS Code** | [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=1Password.1password) | Works in Cursor. Save/retrieve secrets, Secret References, detect secrets, preview `op://` refs. [Docs](https://developer.1password.com/docs/vscode/). |
| **1Password Cursor Hooks** | [1Password Marketplace](https://marketplace.1password.com/integration/cursor-hooks) | Validates that **1Password Environments**mounted `.env` files are present and valid before Cursor Agent runs shell commands. [Cursor Hooks docs](https://developer.1password.com/docs/cursor-hooks), [validate hook guide](https://developer.1password.com/docs/environments/cursor-hook-validate). |
- **VS Code extension:** Install in Cursor via Extensions (search “1Password”). Use **1Password: Get from 1Password** / **Save in 1Password** and Secret References in `.env` and code.
- **Cursor Hooks:** Uses **1Password Environments** with *locally mounted* `.env` files (Mac/Linux; requires 1Password app + `sqlite3`). Clone [1Password/cursor-hooks](https://github.com/1Password/cursor-hooks), add the hook to `.cursor/hooks`, and optionally `.1password/environments.toml` to declare which `.env` paths to validate. Then Cursor Agent only runs commands when those env files are mounted.
---
## 1. Install
- **1Password for VS Code / Cursor**
Install the [1Password extension](https://marketplace.visualstudio.com/items?itemName=1Password.1password) in Cursor. You can then use Secret References in files and autofill from your vault.
- **1Password CLI (`op`)**
Required for running the bot with secrets from 1Password. Full install and sign-in: **[Get started with 1Password CLI](https://developer.1password.com/docs/cli/get-started/)**.
- **Install:** Linux (WSL): `sudo apt install op`; Mac: Homebrew or [manual](https://developer.1password.com/docs/cli/get-started/); Windows: winget or manual.
- **Desktop app:** In 1Password app → **Settings****Developer** → turn on **Integrate with 1Password CLI** (Mac: optional Touch ID; Windows: turn on Windows Hello first; Linux: **Settings****Security****Unlock using system authentication**, then Developer → Integrate).
- **Sign in:** Run `eval $(op signin)` (or any `op` command); youll be prompted to authenticate.
---
## 2. Store secrets in 1Password
Create an item that will hold Broccolini Bot env vars, e.g. **“Broccolini Bot”** or **“Broccolini Bot Production”**.
- **Type:** API Credential or Secure Note.
- **Fields:** Add one custom field per secret. Field names must match the env var names (e.g. `DISCORD_TOKEN`, `MONGODB_URI`, `REFRESH_TOKEN`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, etc.).
Use the same names as in `.env.example` for the sensitive values.
You can use a second item (e.g. **“Broccolini Bot Test”**) with test-only values and point `.env.test` at it (see below).
---
## 3. Use Secret References in `.env`
In `.env` (and optionally `.env.test`), **do not** put real tokens. Use 1Password Secret References instead:
```bash
# Example: vault "Personal", item "Broccolini Bot", field "password" or custom field name
DISCORD_TOKEN=op://Personal/Broccolini Bot/DISCORD_TOKEN
MONGODB_URI=op://Personal/Broccolini Bot/MONGODB_URI
REFRESH_TOKEN=op://Personal/Broccolini Bot/REFRESH_TOKEN
GOOGLE_CLIENT_ID=op://Personal/Broccolini Bot/GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=op://Personal/Broccolini Bot/GOOGLE_CLIENT_SECRET
# ... same for other secrets
```
- Replace **Personal** with your vault name.
- Replace **Broccolini Bot** with your item title (spaces are fine).
- The last part is the **field name** inside that item (e.g. `DISCORD_TOKEN`).
Non-secret values (IDs, ports, feature flags) can stay as plain text in `.env` if you prefer.
To get a reference from the 1Password app: open the item → click a field → “Copy reference”.
---
## 4. Run the bot with 1Password
From the **broccolini-bot** directory:
```bash
op run --env-file=.env -- npm run start
```
For the test env:
```bash
op run --env-file=.env.test -- npm run start:test
```
`op run` reads `.env` (or `.env.test`), resolves every `op://...` value with 1Password, and runs the command with the resolved environment. The bots `config.js` still reads from `process.env` as usual; no code changes are required.
---
## 5. npm scripts (already in package.json)
In `broccolini-bot/`:
- `npm run start:1p` — production env from 1Password (resolves `op://` from `.env`)
- `npm run start:test:1p` — test env from 1Password (resolves `op://` from `.env.test`)
---
## 6. Security notes
- **Do not commit `.env` or `.env.test`.** They are gitignored. Even with Secret References, keep them out of the repo.
- **Rotate secrets** in 1Password when needed; no need to edit local files if you only use references.
- The 1Password Cursor extension can fill Secret References in the editor; the CLI is what actually resolves them when you run the bot.
---
## Quick reference
| Task | Command / step |
|------|----------------|
| **CLI get started** | [Get started with 1Password CLI](https://developer.1password.com/docs/cli/get-started/) (install, desktop integration, sign in) |
| Sign in to CLI | `eval $(op signin)` — required so the session is loaded into your shell; plain `op signin` only prints the commands. |
| Run bot (production) | `op run --env-file=.env -- npm run start` |
| Run bot (test) | `op run --env-file=.env.test -- npm run start:test` |
| Copy a secret reference | 1Password app → item → field → “Copy reference” |

View File

@@ -15,6 +15,8 @@
"scripts": {
"start": "node broccolini-discord.js",
"start:test": "ENV_FILE=.env.test node broccolini-discord.js",
"start:1p": "op run --env-file=.env -- node broccolini-discord.js",
"start:test:1p": "op run --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"
},

225
routes/bosscord.js Normal file
View File

@@ -0,0 +1,225 @@
/**
* bOSScord API routes for broccolini-bot: ticket list/detail, thread from Discord, send message.
* Auth via BOSSCORD_API_KEY. Mount on Express in broccolini-discord.js.
*/
require('../models'); // ensure Ticket model is registered
const express = require('express');
const mongoose = require('mongoose');
const { getBot } = require('../api/bosscordClient');
const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { extractRawEmail } = require('../utils');
const { CONFIG } = require('../config');
const router = express.Router();
const Ticket = mongoose.model('Ticket');
const CORS_ORIGIN = process.env.BOSSCORD_CORS_ORIGIN || '*';
function corsMiddleware(req, res, next) {
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Staff-Discord-Id');
if (req.method === 'OPTIONS') {
return res.sendStatus(204);
}
next();
}
function authMiddleware(req, res, next) {
const key = process.env.BOSSCORD_API_KEY;
if (!key) {
return res.status(503).json({ error: 'bOSScord API not configured (BOSSCORD_API_KEY)' });
}
const auth = req.headers.authorization;
const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
if (token !== key) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
router.use(corsMiddleware);
router.use(authMiddleware);
function requireDb(req, res, next) {
if (mongoose.connection.readyState !== 1) {
return res.status(503).json({ error: 'Database not ready yet. Wait for the bot to finish starting.' });
}
next();
}
router.use(requireDb);
function resolveTicketId(id) {
if (mongoose.Types.ObjectId.isValid(id) && String(new mongoose.Types.ObjectId(id)) === id) {
return Ticket.findOne({ _id: id });
}
const num = parseInt(id, 10);
if (!Number.isNaN(num)) {
return Ticket.findOne({ ticketNumber: num });
}
return Ticket.findOne({ gmailThreadId: id });
}
/** GET /api/tickets — list tickets. Query: status, priority, claimedBy, limit */
router.get('/tickets', async (req, res, next) => {
try {
if (!Ticket) return res.status(503).json({ error: 'Ticket model not loaded' });
const { status, priority, claimedBy, limit = 50 } = req.query;
const filter = {};
if (status) filter.status = status;
if (priority) filter.priority = priority;
if (claimedBy !== undefined && claimedBy !== '') filter.claimedBy = claimedBy === 'null' ? null : claimedBy;
const limitNum = Math.min(parseInt(limit, 10) || 50, 100);
const tickets = await Ticket.find(filter)
.sort({ lastActivity: -1, createdAt: -1 })
.limit(limitNum)
.lean();
return res.json({ tickets });
} catch (err) {
console.error('GET /api/tickets error:', err.message);
console.error(err.stack);
next(err);
}
});
/** GET /api/me/tickets — "my tickets" (claimed by staff). Query: X-Staff-Discord-Id or claimedBy */
router.get('/me/tickets', async (req, res) => {
try {
const claimedBy = req.headers['x-staff-discord-id'] || req.query.claimedBy;
if (!claimedBy) {
return res.status(400).json({ error: 'Provide X-Staff-Discord-Id header or claimedBy query' });
}
const tickets = await Ticket.find({ claimedBy, status: 'open' })
.sort({ lastActivity: -1, createdAt: -1 })
.limit(100)
.lean();
res.json({ tickets });
} catch (err) {
console.error('GET /api/me/tickets:', err);
res.status(500).json({ error: err.message });
}
});
/** GET /api/tickets/:id — single ticket metadata */
router.get('/tickets/:id', async (req, res) => {
try {
const ticket = await resolveTicketId(req.params.id);
if (!ticket) {
return res.status(404).json({ error: 'Ticket not found' });
}
const out = ticket.toObject ? ticket.toObject() : { ...ticket };
if (CONFIG.DISCORD_GUILD_ID) out.guildId = CONFIG.DISCORD_GUILD_ID;
res.json(out);
} catch (err) {
console.error('GET /api/tickets/:id:', err);
res.status(500).json({ error: err.message });
}
});
/** GET /api/tickets/:id/messages — thread from Discord */
router.get('/tickets/:id/messages', async (req, res) => {
try {
const ticket = await resolveTicketId(req.params.id);
if (!ticket) {
return res.status(404).json({ error: 'Ticket not found' });
}
if (!ticket.discordThreadId) {
return res.json({ messages: [] });
}
const client = getBot();
if (!client) {
return res.status(503).json({ error: 'Discord client not ready' });
}
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 100);
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
if (!channel) {
return res.status(404).json({ error: 'Discord channel not found' });
}
const messages = await channel.messages.fetch({ limit });
const list = messages
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
.map((m) => ({
id: m.id,
author: m.author?.username || 'unknown',
authorId: m.author?.id,
content: m.content,
timestamp: m.createdAt?.toISOString?.(),
isBot: m.author?.bot ?? false
}));
res.json({ messages: list });
} catch (err) {
console.error('GET /api/tickets/:id/messages:', err);
res.status(500).json({ error: err.message });
}
});
/** POST /api/tickets/:id/messages — send message to Discord; for email tickets, also send via Gmail */
router.post('/tickets/:id/messages', express.json(), async (req, res) => {
try {
const ticket = await resolveTicketId(req.params.id);
if (!ticket) {
return res.status(404).json({ error: 'Ticket not found' });
}
if (!ticket.discordThreadId) {
return res.status(400).json({ error: 'Ticket has no Discord channel' });
}
const content = req.body?.content;
if (!content || typeof content !== 'string') {
return res.status(400).json({ error: 'Body must include content (string)' });
}
const client = getBot();
if (!client) {
return res.status(503).json({ error: 'Discord client not ready' });
}
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
if (!channel) {
return res.status(404).json({ error: 'Discord channel not found' });
}
const discordUser = req.body.displayName || 'bOSScord';
await channel.send(content);
if (!ticket.gmailThreadId.startsWith('discord-')) {
try {
const gmail = getGmailClient();
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const last = [...(thread.data.messages || [])].reverse().find((msg) => {
const from = msg.payload?.headers?.find((h) => h.name === 'From')?.value || '';
return !from.toLowerCase().includes(CONFIG.MY_EMAIL);
});
if (last?.payload?.headers) {
let recipient = last.payload.headers.find((h) => h.name === 'From')?.value || '';
const replyTo = last.payload.headers.find((h) => h.name === 'Reply-To')?.value;
if (replyTo) recipient = replyTo;
const subject = last.payload.headers.find((h) => h.name === 'Subject')?.value || 'Support';
const msgId = last.payload.headers.find((h) => h.name === 'Message-ID')?.value;
const recipientEmail = extractRawEmail(recipient).toLowerCase();
if (recipientEmail && recipientEmail !== CONFIG.MY_EMAIL) {
await sendGmailReply(
ticket.gmailThreadId,
content,
recipientEmail,
subject,
discordUser,
msgId
);
}
}
} catch (e) {
console.error('bOSScord Gmail reply error:', e);
}
}
await updateTicketActivity(ticket.gmailThreadId);
res.status(201).json({ ok: true });
} catch (err) {
console.error('POST /api/tickets/:id/messages:', err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env node
/**
* Bulk lookup Discord user information - IMPROVED VERSION
*
* Features:
* - Saves progress incrementally (every 100 users)
* - Can resume from where it left off
* - Better error handling
* - Uses guild member cache when possible
*
* Usage:
* node scripts/bulk-lookup-users-v2.js <input_file> <output_file>
*/
const fs = require('fs');
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
// Load environment variables
const envPath = path.join(__dirname, '../../.env');
const result = require('dotenv').config({ path: envPath });
const TOKEN = process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN;
if (!TOKEN) {
console.error('Error: DISCORD_BOT_TOKEN must be set in .env');
process.exit(1);
}
// Parse command line args
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node scripts/bulk-lookup-users-v2.js <input_file> <output_file>');
process.exit(1);
}
const inputFile = args[0];
const outputFile = args[1];
// Read user IDs from input file
const userIds = fs.readFileSync(inputFile, 'utf-8')
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
console.log(`✅ Loaded ${userIds.length} user IDs from ${inputFile}`);
// Load existing results if any (for resume capability)
let results = {};
let processed = 0;
let errors = 0;
if (fs.existsSync(outputFile)) {
try {
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
results = existing.users || {};
processed = Object.keys(results).length;
errors = existing.errors || 0;
console.log(`📂 Found existing results: ${processed} users already processed`);
} catch (e) {
console.log(`⚠️ Could not load existing results, starting fresh`);
}
}
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers
]
});
async function lookupUser(userId) {
// Skip if already processed
if (results[userId]) {
return results[userId];
}
try {
const user = await client.users.fetch(userId);
return {
success: true,
id: user.id,
username: user.username,
globalName: user.globalName || user.username,
tag: user.tag,
bot: user.bot,
avatar: user.displayAvatarURL()
};
} catch (error) {
return {
success: false,
id: userId,
error: error.message,
username: null,
globalName: null,
tag: null,
bot: false
};
}
}
function saveResults() {
const output = {
timestamp: new Date().toISOString(),
total_users: userIds.length,
processed: processed,
successful: processed - errors,
errors: errors,
users: results
};
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
}
async function processUsers() {
console.log('\n🚀 Starting bulk lookup...');
console.log(` Progress will be saved every 100 users\n`);
const startTime = Date.now();
const startProcessed = processed;
// Filter out already processed users
const toProcess = userIds.filter(id => !results[id]);
console.log(` ${toProcess.length} users remaining to process\n`);
// Process one at a time (safer and can still be reasonably fast)
for (let i = 0; i < toProcess.length; i++) {
const userId = toProcess[i];
const result = await lookupUser(userId);
results[result.id] = result;
if (!result.success) {
errors++;
}
processed++;
// Save every 100 users
if (processed % 100 === 0) {
saveResults();
const elapsed = ((Date.now() - startTime) / 1000);
const rate = (processed - startProcessed) / elapsed;
const remaining = (toProcess.length - i - 1) / rate;
console.log(`💾 Progress: ${processed}/${userIds.length} (${errors} errors) - saved checkpoint - ~${remaining.toFixed(0)}s remaining`);
}
// Slower delay to avoid rate limits (500ms = 2 requests/second - more reliable)
await new Promise(resolve => setTimeout(resolve, 500));
}
// Final save
saveResults();
const totalTime = ((Date.now() - startTime) / 1000);
console.log(`\n${'='.repeat(70)}`);
console.log(`✅ Lookup Complete!`);
console.log(`${'='.repeat(70)}`);
console.log(` Total time: ${totalTime.toFixed(1)}s`);
console.log(` Total processed: ${processed}/${userIds.length}`);
console.log(` Successful: ${processed - errors} (${((processed - errors)/userIds.length*100).toFixed(1)}%)`);
console.log(` Errors: ${errors}`);
console.log(` Rate: ${((processed - startProcessed)/totalTime).toFixed(1)} users/second`);
console.log(`\n💾 Saved to: ${outputFile}\n`);
// Sample successful results
const sample = Object.values(results).filter(r => r.success).slice(0, 5);
if (sample.length > 0) {
console.log('📋 Sample results:');
sample.forEach(u => console.log(` ${u.username} (${u.id})`));
}
process.exit(0);
}
client.once('ready', () => {
console.log(`✅ Bot logged in as ${client.user.tag}\n`);
processUsers();
});
client.on('error', (error) => {
console.error('❌ Discord client error:', error);
});
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n\n⚠ Interrupted! Saving progress...');
saveResults();
console.log('✅ Progress saved. You can resume by running the same command again.\n');
process.exit(0);
});
client.login(TOKEN);

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* Bulk lookup Discord user information
*
* Usage:
* node scripts/bulk-lookup-users.js <input_file> <output_file>
*
* Input file: Text file with one user ID per line
* Output file: JSON file with user lookup results
*/
const fs = require('fs');
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
// Load environment variables from repo root
const envPath = path.join(__dirname, '../../.env');
console.log(`Loading .env from: ${envPath}`);
const result = require('dotenv').config({ path: envPath });
if (result.error) {
console.error(`Error loading .env: ${result.error.message}`);
// Try broccolini-bot/.env as fallback
require('dotenv').config({ path: path.join(__dirname, '../.env') });
}
const TOKEN = process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN;
const GUILD_ID = process.env.GUILD_ID || process.env.SERVER_ID;
if (!TOKEN) {
console.error('Error: DISCORD_BOT_TOKEN must be set in .env');
console.error('Available env vars:', Object.keys(process.env).filter(k => k.includes('DISCORD')));
process.exit(1);
}
// Parse command line args
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node scripts/bulk-lookup-users.js <input_file> <output_file>');
process.exit(1);
}
const inputFile = args[0];
const outputFile = args[1];
// Read user IDs from input file
const userIds = fs.readFileSync(inputFile, 'utf-8')
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
console.log(`Loaded ${userIds.length} user IDs from ${inputFile}`);
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers
]
});
const results = {};
let processed = 0;
let errors = 0;
async function lookupUser(userId) {
try {
// Add timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Lookup timeout')), 10000)
);
const fetchPromise = client.users.fetch(userId);
const user = await Promise.race([fetchPromise, timeoutPromise]);
return {
success: true,
id: user.id,
username: user.username,
globalName: user.globalName || user.username,
tag: user.tag,
bot: user.bot,
avatar: user.displayAvatarURL()
};
} catch (error) {
// Handle errors (not found, timeout, rate limit)
if (error.message.includes('429')) {
console.log(` ⚠️ Rate limit hit for user ${userId}, will retry`);
}
return {
success: false,
id: userId,
error: error.message,
username: null,
globalName: null,
tag: null,
bot: false
};
}
}
async function processUsers() {
console.log('\nStarting bulk lookup...');
console.log('This will take a few minutes for 2,428 users\n');
const startTime = Date.now();
// Process in batches to avoid rate limits
const BATCH_SIZE = 3; // Very small batches to avoid rate limits
const DELAY_MS = 2000; // 2 seconds between batches
for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
const batch = userIds.slice(i, i + BATCH_SIZE);
// Lookup batch in parallel
const promises = batch.map(userId => lookupUser(userId));
const batchResults = await Promise.all(promises);
// Store results
batchResults.forEach(result => {
if (!result.success) {
errors++;
}
results[result.id] = result;
processed++;
});
// Log every batch for debugging
if (processed <= 50) {
console.log(` Batch complete: ${processed} users processed`);
}
// Progress update every 100 users
if (processed % 100 === 0 || processed === userIds.length) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const rate = (processed / elapsed).toFixed(1);
const remaining = ((userIds.length - processed) / rate).toFixed(0);
console.log(`Progress: ${processed}/${userIds.length} (${errors} errors) - ${elapsed}s elapsed, ~${remaining}s remaining`);
}
// Wait before next batch to avoid rate limits
if (i + BATCH_SIZE < userIds.length) {
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
}
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\n✅ Completed in ${totalTime}s`);
console.log(` Successful: ${processed - errors}`);
console.log(` Errors: ${errors}`);
// Save results
const output = {
timestamp: new Date().toISOString(),
total_users: userIds.length,
successful: processed - errors,
errors: errors,
users: results
};
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
console.log(`\n💾 Saved results to ${outputFile}`);
process.exit(0);
}
client.once('ready', () => {
console.log(`✅ Bot logged in as ${client.user.tag}`);
processUsers();
});
client.on('error', (error) => {
console.error('Discord client error:', error);
});
client.login(TOKEN);

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Export transcript channel messages with embed "Users in transcript" to JSONL.
* Each line: { message_id, created, ticket_name, ticket_owner_id, users: [{ id, count }], total }
* Usage: node scripts/export-transcript-embeds.js <channelId> [maxMessages] [outputPath]
* If outputPath is omitted, writes to stdout (redirect: node ... > transcript_embeds.jsonl).
* If outputPath is given, writes JSONL to that file (avoids dotenv/logs mixing with JSON).
*/
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const fs = require('fs');
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const channelId = process.argv[2];
const maxMessages = parseInt(process.argv[3], 10) || 10000;
const outputPath = process.argv[4];
const PAGE = 100;
// Parse "Users in transcript" value: "5 - <@123> - name#0\n 4 - <@456> - ..."
function parseUsersInTranscript(value) {
const users = [];
let total = 0;
const lines = (value || '').split(/\n/).map((s) => s.trim()).filter(Boolean);
for (const line of lines) {
const match = line.match(/^(\d+)\s+-\s+<@!?(\d+)>/);
if (match) {
const count = parseInt(match[1], 10);
users.push({ id: match[2], count });
total += count;
}
}
return { users, total };
}
if (!TOKEN || !channelId) {
console.error('Usage: node scripts/export-transcript-embeds.js <channelId> [maxMessages]');
process.exit(1);
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});
client.once('ready', async () => {
try {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel) {
console.error('Channel not found or bot cannot access it.');
process.exit(1);
}
if (outputPath) {
fs.writeFileSync(outputPath, '');
}
let totalScanned = 0;
let before = undefined;
while (totalScanned < maxMessages) {
const limit = Math.min(PAGE, maxMessages - totalScanned);
const options = before ? { limit, before } : { limit };
const messages = await channel.messages.fetch(options);
if (messages.size === 0) break;
totalScanned += messages.size;
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
if (!m.embeds?.length) continue;
for (const emb of m.embeds) {
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
if (!usersField?.value) continue;
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
const ticketName = ticketNameField?.value?.trim() || '';
const ownerField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket owner'));
const ownerMatch = ownerField?.value?.match(/<@!?(\d+)>/);
const ticket_owner_id = ownerMatch ? ownerMatch[1] : null;
const { users, total } = parseUsersInTranscript(usersField.value);
if (users.length === 0 && !ticket_owner_id) continue;
const out = {
message_id: m.id,
created: m.createdAt.toISOString(),
ticket_name: ticketName,
ticket_owner_id: ticket_owner_id || undefined,
users,
total,
};
const line = JSON.stringify(out) + '\n';
if (outputPath) {
fs.appendFileSync(outputPath, line);
} else {
process.stdout.write(line);
}
}
}
const oldestMsg = messages.reduce((a, m) => (m.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? m : a), null);
before = oldestMsg?.id;
if (messages.size < PAGE) break;
}
process.stderr.write('Scanned ' + totalScanned + ' messages\n');
} catch (e) {
console.error(e.message || e);
} finally {
client.destroy();
process.exit(0);
}
});
client.login(TOKEN).catch((e) => {
console.error('Login failed:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* Fetch recent messages from a Discord channel.
* Usage: node scripts/fetch-channel-messages.js <channelId> [limit]
* Default limit: 10
*/
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const channelId = process.argv[2];
const limit = Math.min(parseInt(process.argv[3], 10) || 10, 100);
if (!TOKEN || !channelId) {
console.error('Usage: node scripts/fetch-channel-messages.js <channelId> [limit]');
process.exit(1);
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});
client.once('ready', async () => {
try {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel) {
console.log('Channel not found or bot cannot access it.');
process.exit(0);
}
const messages = await channel.messages.fetch({ limit });
console.log('Channel:', channel.name, '(' + channel.id + ')');
console.log('Messages fetched:', messages.size, '(requested', limit + ')');
if (messages.size === 0) {
console.log('No messages visible (empty channel or no Read Message History permission).');
process.exit(0);
}
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
const preview = (m.content || '(embed/attachment only)').slice(0, 80);
console.log('---');
console.log('ID:', m.id, '| Author:', m.author.tag, '|', m.createdAt.toISOString());
console.log(preview + (m.content && m.content.length > 80 ? '...' : ''));
}
} catch (e) {
console.error(e.message || e);
} finally {
client.destroy();
process.exit(0);
}
});
client.login(TOKEN).catch((e) => {
console.error('Login failed:', e.message);
process.exit(1);
});

51
scripts/fetch-channel.js Normal file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
/**
* Fetch a Discord channel by ID and print its name and type.
* Usage: node scripts/fetch-channel.js <channelId>
* Example: node scripts/fetch-channel.js 1335424071227281520
*
* Uses DISCORD_BOT_TOKEN or MEMBER_BOT_TOKEN from .env (broccolini-bot or parent).
*/
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const channelId = process.argv[2];
if (!TOKEN) {
console.error('❌ No bot token (DISCORD_BOT_TOKEN or MEMBER_BOT_TOKEN)');
process.exit(1);
}
if (!channelId) {
console.error('Usage: node scripts/fetch-channel.js <channelId>');
process.exit(1);
}
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once('ready', async () => {
try {
const channel = await client.channels.fetch(channelId).catch((err) => null);
if (!channel) {
console.log('Channel not found or bot cannot access it.');
process.exit(0);
}
console.log('Channel ID:', channel.id);
console.log('Name:', channel.name);
console.log('Type:', channel.type);
if (channel.guild) console.log('Guild:', channel.guild.name, `(${channel.guild.id})`);
} catch (e) {
console.error(e.message || e);
} finally {
client.destroy();
process.exit(0);
}
});
client.login(TOKEN).catch((e) => {
console.error('Login failed:', e.message);
process.exit(1);
});

71
scripts/fetch-message.js Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* Fetch a Discord message by channel ID and message ID.
* Usage: node scripts/fetch-message.js <channelId> <messageId>
*/
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const [channelId, messageId] = process.argv.slice(2);
if (!TOKEN || !channelId || !messageId) {
console.error('Usage: node scripts/fetch-message.js <channelId> <messageId>');
process.exit(1);
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});
client.once('ready', async () => {
try {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel) {
console.log('Channel not found or bot cannot access it.');
process.exit(0);
}
const message = await channel.messages.fetch(messageId).catch((err) => null);
if (!message) {
console.log('Message not found (wrong channel, deleted, or no access).');
process.exit(0);
}
console.log('Channel:', channel.name, '(' + channel.id + ')');
console.log('Message ID:', message.id);
console.log('Author:', message.author.tag, '(' + message.author.id + ')');
console.log('Created:', message.createdAt ? message.createdAt.toISOString() : message.createdTimestamp);
console.log('Content:', message.content || '(empty or embed only)');
if (message.embeds && message.embeds.length) {
message.embeds.forEach((emb, i) => {
console.log('\n--- Embed', i + 1, '---');
if (emb.title) console.log('Title:', emb.title);
if (emb.description) console.log('Description:', emb.description);
if (emb.url) console.log('URL:', emb.url);
if (emb.fields && emb.fields.length) {
emb.fields.forEach((f) => console.log('Field:', f.name, '\n', f.value));
}
if (emb.footer?.text) console.log('Footer:', emb.footer.text);
// Ticket name for display (e.g. "indifferentketchup🍅" from "indifferentketchup🍅-claimed-7235")
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
if (ticketNameField?.value) {
const full = ticketNameField.value.trim();
const short = full.replace(/-claimed-\d+$/, '').trim();
console.log('Ticket (short):', short || full);
}
});
}
} catch (e) {
console.error(e.message || e);
} finally {
client.destroy();
process.exit(0);
}
});
client.login(TOKEN).catch((e) => {
console.error('Login failed:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Find transcript channel messages whose embed "Users in transcript" lists a given member ID.
* Usage: node scripts/find-transcript-by-member.js <channelId> <memberId> [maxMessages]
* Example: node scripts/find-transcript-by-member.js 1335424071227281520 219276746153787392 500
* Fetches in pages of 100; maxMessages limits total (e.g. 500 = 5 pages). Default 100.
*/
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const channelId = process.argv[2];
const memberId = process.argv[3];
const maxMessages = parseInt(process.argv[4], 10) || 100;
const PAGE = 100;
if (!TOKEN || !channelId || !memberId) {
console.error('Usage: node scripts/find-transcript-by-member.js <channelId> <memberId> [maxMessages]');
process.exit(1);
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});
client.once('ready', async () => {
try {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel) {
console.log('Channel not found or bot cannot access it.');
process.exit(0);
}
console.log('Channel:', channel.name, '(' + channel.id + ')');
console.log('Looking for member ID', memberId, 'in embed "Users in transcript"');
const memberRef = `<@${memberId}>`;
let totalScanned = 0;
let found = 0;
let before = undefined;
while (totalScanned < maxMessages) {
const limit = Math.min(PAGE, maxMessages - totalScanned);
const options = before ? { limit, before } : { limit };
const messages = await channel.messages.fetch(options);
if (messages.size === 0) break;
totalScanned += messages.size;
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
if (!m.embeds?.length) continue;
for (const emb of m.embeds) {
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
if (!usersField?.value || !usersField.value.includes(memberRef)) continue;
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
const ticketName = ticketNameField?.value?.trim() || '(no Ticket Name field)';
console.log('\n--- Match ---');
console.log('Message ID:', m.id);
console.log('Created:', m.createdAt.toISOString());
console.log('Ticket Name:', ticketName);
console.log('Users in transcript:\n' + usersField.value);
found++;
}
}
const oldestMsg = messages.reduce((a, m) => (m.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? m : a), null);
before = oldestMsg?.id;
if (messages.size < PAGE) break;
}
console.log('\nTotal messages scanned:', totalScanned);
console.log('Total messages matching member', memberId, ':', found);
} catch (e) {
console.error(e.message || e);
} finally {
client.destroy();
process.exit(0);
}
});
client.login(TOKEN).catch((e) => {
console.error('Login failed:', e.message);
process.exit(1);
});

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Find transcript messages whose embed "Ticket Owner" is a given user ID.
* Usage: node scripts/find-transcript-by-owner.js <channelId> <ownerId> [totalMessages] [maxMessages]
* If totalMessages is given, only show messages where "Users in transcript" sum equals that.
* Example: node scripts/find-transcript-by-owner.js 1335424071227281520 241129484483297280 5 10000
*/
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const channelId = process.argv[2];
const ownerId = process.argv[3];
const totalMessages = parseInt(process.argv[4], 10) || null;
const maxMessages = parseInt(process.argv[5], 10) || 10000;
const PAGE = 100;
function parseUsersTotal(value) {
let total = 0;
(value || '').split(/\n/).forEach((line) => {
const m = line.trim().match(/^(\d+)\s+-\s+<@!?\d+>/);
if (m) total += parseInt(m[1], 10);
});
return total;
}
if (!TOKEN || !channelId || !ownerId) {
console.error('Usage: node scripts/find-transcript-by-owner.js <channelId> <ownerId> [totalMessages] [maxMessages]');
process.exit(1);
}
const ownerRef = `<@${ownerId}>`;
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});
client.once('ready', async () => {
try {
const channel = await client.channels.fetch(channelId).catch(() => null);
if (!channel) {
console.error('Channel not found or bot cannot access it.');
process.exit(1);
}
console.error('Channel:', channel.name, '(' + channel.id + ')');
console.error('Looking for Ticket Owner', ownerId, totalMessages != null ? 'and total=' + totalMessages : '');
let totalScanned = 0;
let before = undefined;
let found = 0;
while (totalScanned < maxMessages) {
const limit = Math.min(PAGE, maxMessages - totalScanned);
const options = before ? { limit, before } : { limit };
const messages = await channel.messages.fetch(options);
if (messages.size === 0) break;
totalScanned += messages.size;
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
if (!m.embeds?.length) continue;
for (const emb of m.embeds) {
const ownerField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket owner'));
if (!ownerField?.value || !ownerField.value.includes(ownerRef)) continue;
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
const total = usersField?.value ? parseUsersTotal(usersField.value) : 0;
if (totalMessages != null && total !== totalMessages) continue;
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
const ticketName = ticketNameField?.value?.trim() || '';
console.log('Message ID:', m.id);
console.log('Created:', m.createdAt.toISOString());
console.log('Ticket Name:', ticketName);
console.log('Total messages:', total);
console.log('---');
found++;
}
}
const oldestMsg = messages.reduce((a, msg) => (msg.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? msg : a), null);
before = oldestMsg?.id;
if (messages.size < PAGE) break;
}
console.error('Scanned', totalScanned, 'messages, matches:', found);
} catch (e) {
console.error(e.message || e);
} finally {
client.destroy();
process.exit(0);
}
});
client.login(TOKEN).catch((e) => {
console.error('Login failed:', e.message);
process.exit(1);
});

39
scripts/lookup-user.js Normal file
View File

@@ -0,0 +1,39 @@
/**
* Look up a Discord user by ID. Uses repo root .env for token so it works without broccolini-bot config.
* Usage: node scripts/lookup-user.js [user_id]
* Run from broccolini-bot/ (or use full path to script).
*/
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
const token = (process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN || '').trim();
if (!token) {
console.error('Set DISCORD_BOT_TOKEN or DISCORD_TOKEN in repo root .env (/IB-Discord-Bot/.env)');
process.exit(1);
}
const { Client, GatewayIntentBits } = require('discord.js');
const userId = process.argv[2] || '140081819986034688';
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once('ready', async () => {
try {
const user = await client.users.fetch(userId);
console.log('User:', {
id: user.id,
username: user.username,
globalName: user.globalName ?? user.username,
tag: user.tag,
bot: user.bot
});
} catch (err) {
console.error('Lookup failed:', err.message);
if (err.code === 10013) console.error('Unknown user, or bot does not share a server with this user.');
} finally {
client.destroy();
process.exit(0);
}
});
client.login(token);

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* User lookup using a dedicated minimal-permissions bot
*
* This bot:
* - Has NO server permissions
* - Only needs to be in the server
* - Uses separate token from main bot
* - Won't affect your main bot's rate limits
*
* Usage:
* LOOKUP_BOT_TOKEN=your_token node scripts/lookup-with-dedicated-bot.js <input_file> <output_file>
*/
const fs = require('fs');
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
// Load environment
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
// Use dedicated bot token OR fall back to main bot
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
if (!TOKEN) {
console.error('❌ Error: No bot token found');
console.error(' Set MEMBER_BOT_TOKEN in .env or use DISCORD_BOT_TOKEN');
process.exit(1);
}
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node scripts/lookup-with-dedicated-bot.js <input_file> <output_file>');
process.exit(1);
}
const inputFile = args[0];
const outputFile = args[1];
// Read user IDs
const userIds = fs.readFileSync(inputFile, 'utf-8')
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
console.log(`✅ Loaded ${userIds.length} user IDs`);
// Load existing results
let results = {};
let processed = 0;
let errors = 0;
if (fs.existsSync(outputFile)) {
try {
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
results = existing.users || {};
processed = Object.keys(results).length;
errors = existing.errors || 0;
console.log(`📂 Found existing: ${processed} users`);
} catch (e) {
console.log(`⚠️ Starting fresh`);
}
}
// Create bot with MINIMAL intents
const client = new Client({
intents: [
GatewayIntentBits.Guilds // Only need this to stay in server
// NO other intents needed!
]
});
async function lookupUser(userId) {
if (results[userId]) return results[userId];
try {
const user = await client.users.fetch(userId);
return {
success: true,
id: user.id,
username: user.username,
globalName: user.globalName || user.username,
tag: user.tag,
bot: user.bot,
avatar: user.displayAvatarURL()
};
} catch (error) {
return {
success: false,
id: userId,
error: error.message,
username: null,
globalName: null,
tag: null,
bot: false
};
}
}
function saveResults() {
const output = {
timestamp: new Date().toISOString(),
total_users: userIds.length,
processed: processed,
successful: processed - errors,
errors: errors,
bot_type: (process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN) ? 'dedicated' : 'main',
users: results
};
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
}
async function processUsers() {
console.log('\n🚀 Starting lookups...');
const isDedicated = !!(process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN);
console.log(` Bot type: ${isDedicated ? '✅ Dedicated lookup bot' : '⚠️ Main bot'}`);
console.log(` Rate: SLOW (1 user/second for safety)`);
console.log();
const startTime = Date.now();
const toProcess = userIds.filter(id => !results[id]);
console.log(` ${toProcess.length} users remaining\n`);
for (let i = 0; i < toProcess.length; i++) {
const userId = toProcess[i];
const result = await lookupUser(userId);
results[result.id] = result;
if (!result.success) errors++;
processed++;
// Save every 10 users for frequent updates
if (processed % 10 === 0) {
saveResults();
const elapsed = (Date.now() - startTime) / 1000;
const rate = (processed - (userIds.length - toProcess.length)) / elapsed;
const remaining = (toProcess.length - i - 1) / rate;
console.log(`💾 ${processed}/${userIds.length} (${errors} errors) - saved - ~${remaining.toFixed(0)}s left`);
}
// Very slow to avoid rate limits (1/second)
await new Promise(resolve => setTimeout(resolve, 1000));
}
saveResults();
const totalTime = (Date.now() - startTime) / 1000;
console.log(`\n${'='.repeat(60)}`);
console.log(`✅ Complete!`);
console.log(`${'='.repeat(60)}`);
console.log(` Time: ${totalTime.toFixed(1)}s`);
console.log(` Processed: ${processed}/${userIds.length}`);
console.log(` Successful: ${processed - errors}`);
console.log(` Errors: ${errors}`);
console.log(`\n💾 Saved to: ${outputFile}\n`);
process.exit(0);
}
client.once('ready', () => {
const isDedicated = !!(process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN);
const botType = isDedicated ? 'DEDICATED LOOKUP BOT' : 'Main Bot';
console.log(`✅ Logged in as ${client.user.tag}`);
console.log(` Type: ${botType}`);
console.log();
processUsers();
});
client.on('error', (error) => {
console.error('❌ Error:', error.message);
});
process.on('SIGINT', () => {
console.log('\n\n⚠ Interrupted! Saving...');
saveResults();
console.log('✅ Saved. Resume by running same command.\n');
process.exit(0);
});
console.log('🔌 Connecting to Discord...');
client.login(TOKEN);

View File

@@ -0,0 +1,237 @@
#!/usr/bin/env node
/**
* Discord user lookup WITH ROLES
*
* Fetches:
* - User info (username, display name, avatar)
* - Guild member info (roles, join date, server nickname)
* - All Palpocalypse server roles
*
* Requires: Server Members Intent enabled in Discord Developer Portal
*/
const fs = require('fs');
const path = require('path');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const GUILD_ID = '798321161082896395'; // Indifferent Broccoli server
if (!TOKEN) {
console.error('❌ Error: No bot token found');
process.exit(1);
}
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node scripts/lookup-with-roles.js <input_file> <output_file>');
process.exit(1);
}
const inputFile = args[0];
const outputFile = args[1];
const userIds = fs.readFileSync(inputFile, 'utf-8')
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
console.log(`✅ Loaded ${userIds.length} user IDs`);
let results = {};
let processed = 0;
let errors = 0;
if (fs.existsSync(outputFile)) {
try {
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
results = existing.users || {};
processed = Object.keys(results).length;
errors = existing.errors || 0;
console.log(`📂 Found existing: ${processed} users`);
} catch (e) {
console.log(`⚠️ Starting fresh`);
}
}
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers // Required for roles!
]
});
let guild = null;
async function lookupUserWithRoles(userId) {
if (results[userId]) return results[userId];
try {
// Fetch basic user info
const user = await client.users.fetch(userId);
// Try to fetch guild member (for roles)
let roles = [];
let serverNickname = null;
let joinedAt = null;
let isInServer = false;
try {
const member = await guild.members.fetch(userId);
isInServer = true;
serverNickname = member.nickname;
joinedAt = member.joinedAt ? member.joinedAt.toISOString() : null;
// Get all roles except @everyone
roles = member.roles.cache
.filter(role => role.name !== '@everyone')
.map(role => ({
id: role.id,
name: role.name,
color: role.hexColor,
position: role.position
}))
.sort((a, b) => b.position - a.position); // Highest role first
} catch (memberError) {
// User exists but not in this server
isInServer = false;
}
return {
success: true,
id: user.id,
username: user.username,
globalName: user.globalName || user.username,
tag: user.tag,
bot: user.bot,
avatar: user.displayAvatarURL(),
// Server-specific data
server_nickname: serverNickname,
joined_at: joinedAt,
in_server: isInServer,
roles: roles,
role_names: roles.map(r => r.name),
highest_role: roles[0]?.name || null
};
} catch (error) {
return {
success: false,
id: userId,
error: error.message,
username: null,
globalName: null,
roles: []
};
}
}
function saveResults() {
const output = {
timestamp: new Date().toISOString(),
total_users: userIds.length,
processed: processed,
successful: processed - errors,
errors: errors,
guild_id: GUILD_ID,
includes_roles: true,
users: results
};
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
}
async function processUsers() {
console.log('\n🎭 Starting lookups WITH ROLES...');
console.log(` Guild ID: ${GUILD_ID}`);
console.log(` Rate: 1 user/second\n`);
// Fetch guild
guild = await client.guilds.fetch(GUILD_ID);
console.log(`✅ Connected to: ${guild.name}\n`);
const startTime = Date.now();
const toProcess = userIds.filter(id => !results[id]);
console.log(` ${toProcess.length} users remaining\n`);
for (let i = 0; i < toProcess.length; i++) {
const userId = toProcess[i];
const result = await lookupUserWithRoles(userId);
results[result.id] = result;
if (!result.success) errors++;
processed++;
// Save every 10 users
if (processed % 10 === 0) {
saveResults();
const elapsed = (Date.now() - startTime) / 1000;
const rate = (processed - (userIds.length - toProcess.length)) / elapsed;
const remaining = (toProcess.length - i - 1) / rate;
// Show sample with roles
if (result.success && result.roles.length > 0) {
const rolePreview = result.role_names.slice(0, 2).join(', ');
console.log(`💾 ${processed}/${userIds.length} - ${result.globalName} [${rolePreview}] - ~${remaining.toFixed(0)}s left`);
} else {
console.log(`💾 ${processed}/${userIds.length} (${errors} errors) - ~${remaining.toFixed(0)}s left`);
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
saveResults();
const totalTime = (Date.now() - startTime) / 1000;
// Stats
const usersWithRoles = Object.values(results).filter(u => u.success && u.roles.length > 0).length;
const allRoleNames = new Set();
Object.values(results).forEach(u => {
if (u.success) {
u.role_names?.forEach(r => allRoleNames.add(r));
}
});
console.log(`\n${'='.repeat(70)}`);
console.log(`✅ Complete with Roles!`);
console.log(`${'='.repeat(70)}`);
console.log(` Time: ${totalTime.toFixed(1)}s`);
console.log(` Processed: ${processed}/${userIds.length}`);
console.log(` Successful: ${processed - errors}`);
console.log(` Users with roles: ${usersWithRoles}`);
console.log(` Unique roles found: ${allRoleNames.size}`);
console.log(`\n💾 Saved to: ${outputFile}\n`);
// Show some roles
if (allRoleNames.size > 0) {
console.log('📋 Sample roles found:');
Array.from(allRoleNames).slice(0, 10).forEach(r => console.log(`${r}`));
}
process.exit(0);
}
client.once('ready', () => {
console.log(`✅ Logged in as ${client.user.tag}\n`);
processUsers();
});
client.on('error', (error) => {
console.error('❌ Error:', error.message);
});
process.on('SIGINT', () => {
console.log('\n\n⚠ Interrupted! Saving...');
saveResults();
console.log('✅ Saved. Resume by running same command.\n');
process.exit(0);
});
console.log('🔌 Connecting to Discord...');
client.login(TOKEN);

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* Map batch tickets (TICKET: guild_channelId_suffix) to transcript channel messages.
*
* Connection:
* - Batch line: TICKET: 798321161082896395_1423340928588054621_indiffe → channelId = 1423340928588054621.
* - Transcript channel (🖥│transcripts): each message is an embed with "Ticket Name: indifferentketchup🍅-claimed-7235".
* - Embed does NOT include channel ID, so we match by (1) ticket name (when known) or (2) time: transcript posted when ticket closes.
*
* Usage:
* node scripts/map-batch-to-transcript.js list [limit] -- fetch transcript messages, output CSV (messageId, created, ticket_name)
* node scripts/map-batch-to-transcript.js find <channelId> -- find transcript message(s) likely for this ticket (by time window)
*
* Known mapping (from embed): 1423340928588054621 ↔ message 1423400708769579120 (Ticket: indifferentketchup🍅-claimed-7235).
*/
const path = require('path');
const fs = require('fs');
const { Client, GatewayIntentBits } = require('discord.js');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
const TRANSCRIPT_CHANNEL_ID = '1335424071227281520';
const METRICS_CSV = path.join(__dirname, '../../Discord Ticket Transcripts/transcript_metrics_per_ticket.csv');
function getTicketNameFromEmbed(emb) {
const f = emb.fields?.find((x) => x.name && x.name.toLowerCase().includes('ticket name'));
return f ? f.value.trim() : null;
}
async function fetchTranscriptMessages(client, limit = 100) {
const channel = await client.channels.fetch(TRANSCRIPT_CHANNEL_ID).catch(() => null);
if (!channel) return [];
const cap = Math.min(limit, 100); // Discord API max 100 per request
const messages = await channel.messages.fetch({ limit: cap });
const out = [];
for (const [, m] of messages) {
const emb = m.embeds?.[0];
const ticketName = emb ? getTicketNameFromEmbed(emb) : null;
out.push({
messageId: m.id,
created: m.createdAt ? m.createdAt.toISOString() : m.createdTimestamp,
createdTs: m.createdTimestamp,
ticketName: ticketName || '',
});
}
out.sort((a, b) => b.createdTs - a.createdTs);
return out;
}
function loadMetricsCsv() {
if (!fs.existsSync(METRICS_CSV)) return [];
const text = fs.readFileSync(METRICS_CSV, 'utf8');
const lines = text.split(/\r?\n/).filter((l) => l.trim());
const header = lines[0].split(',');
const ticketIdIdx = header.indexOf('ticket_id');
const lastTsIdx = header.indexOf('last_message_ts');
if (ticketIdIdx === -1 || lastTsIdx === -1) return [];
const rows = [];
for (let i = 1; i < lines.length; i++) {
const parts = lines[i].split(',');
const ticketId = parts[ticketIdIdx];
const lastTs = parseInt(parts[lastTsIdx], 10);
if (!ticketId || !ticketId.includes('_')) continue;
const channelId = ticketId.split('_')[1];
if (channelId && !isNaN(lastTs)) rows.push({ ticketId, channelId, last_message_ts: lastTs });
}
return rows;
}
async function main() {
const cmd = process.argv[2];
const arg = process.argv[3];
if (!TOKEN) {
console.error('No bot token');
process.exit(1);
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
});
await new Promise((resolve, reject) => {
client.once('ready', resolve);
client.login(TOKEN).catch(reject);
});
try {
if (cmd === 'list') {
const limit = Math.min(parseInt(arg, 10) || 100, 100);
const list = await fetchTranscriptMessages(client, limit);
console.log('transcript_message_id,created_iso,ticket_name');
list.forEach((r) => console.log([r.messageId, r.created, r.ticketName].map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')));
return;
}
if (cmd === 'find' && arg) {
const channelId = arg.trim();
const metrics = loadMetricsCsv();
const row = metrics.find((r) => r.channelId === channelId);
const closeTs = row ? row.last_message_ts : null;
const list = await fetchTranscriptMessages(client, 100);
const windowMs = 2 * 60 * 60 * 1000; // ±2 hours
const candidates = closeTs
? list.filter((r) => Math.abs(r.createdTs - closeTs) <= windowMs)
: list.slice(0, 20);
console.log('Batch ticket channelId:', channelId);
if (row) console.log('Ticket close time (last_message_ts):', closeTs, new Date(closeTs).toISOString());
console.log('Transcript channel messages (candidates by time or recent):');
candidates.forEach((r) => {
const delta = closeTs != null ? (r.createdTs - closeTs) / 60000 : null;
console.log(' ', r.messageId, r.created, r.ticketName || '(no name)', delta != null ? `delta ${delta.toFixed(0)} min` : '');
});
return;
}
console.log('Usage: node scripts/map-batch-to-transcript.js list [limit]');
console.log(' node scripts/map-batch-to-transcript.js find <channelId>');
} finally {
client.destroy();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});