8 Commits

Author SHA1 Message Date
0fcffe8d33 Email replies: add spacing between signature and quoted message
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:44:25 +00:00
2ccdbf72aa Email ticketing fixes, comms polish, and .env cleanup
Inbound:
- Gmail poll query is:unread in:inbox (was category:primary, which matched
  nothing on a no-tabs Workspace inbox)

Outbound email:
- Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new
  TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails
- Replies quote the customer's latest message (gmail_quote markup so clients
  collapse it), embed custom emoji inline via CID attachment, and strip Discord
  role mentions
- Tagline spacing fix in the company signature

Discord side:
- Suppress all mentions in log + transcript posts (no more pinging on close)
- Drop the staff-role ping from new-ticket and follow-up notifications
- Ticket channels inherit category permissions instead of setting per-channel
  overwrites (removes the Manage Roles requirement)

Gmail folders:
- Folder/label routing (gmailLabels.js) with /folder; close files to Complete

Config:
- Remove ~56 stale .env keys for long-removed features; refresh stale copy

Docs:
- Design specs for folder routing, email-flow toggle, and per-staff metrics
2026-06-04 22:05:20 +00:00
3e20f9cf86 Merge: dead-code removal + close/escalation dedup 2026-06-02 19:59:20 +00:00
2fab3b97bf Remove dead/stale code, dedup close+escalation paths
Dead/stale removals (grep-confirmed no consumers):
- config: drop 9 unread CONFIG keys (ROLE_TO_PING_ID, SIGNATURE,
  REMINDER_*, RENAME_LOG_CHANNEL_ID, SETTINGS_*); remove their
  ALLOWED_CONFIG_KEYS entries and the orphaned settings-site UI fields
- configSchema: delete unreachable json/string_or_json validators
- models: drop unused ticketTag field
- gmail-poll: remove unused isPollSuspended export
- utils: remove dead htmlToTextWithBlocks/decodeHtmlEntities/BLOCK_TAG_REGEX
- internalApi: remove router._allowedKeys (test it served is gone)
- discord client: drop unused GuildPresences privileged intent
- broccolini-discord: remove dormant /api 503 gate (no /api routes)

Fixes:
- context-menu ticket create now uses makeTicketName('unclaimed', ...)
  instead of the contract-violating ticket-<n> name
- drop write-only pending.userId from both close paths

Dedup / simplify:
- new services/transcript.js shares the transcript text/date/header
  builders between the button and force-close paths (had drifted)
- resolveEscalationCategoryId() replaces 3 copies of the category logic
- ticketChannelOverwrites() shares the create-permission array between
  the two interactive ticket-create paths
- finalizeBody() shares the email-cleanup tail in parseGmailMessage
- getTicketActionRow drops its never-passed options arg;
  sendTicketNotificationEmail drops its always-null subjectLine arg
- hoist invariant guild lookup out of the auto-close/unclaim loops
- drop redundant lastActivity write (and now-dead updateTicketActivity)
- /help lists all current commands and the right-click apps
2026-06-02 19:59:14 +00:00
a388d99fdf /transfer: validate target via isStaff() — covers ADDITIONAL_STAFF_ROLES
The transfer-target check previously matched only against
CONFIG.ROLE_TO_PING_ID, so a member with one of
CONFIG.ADDITIONAL_STAFF_ROLES (a recognized staff role everywhere else
in the bot, including requireStaffRole and the messages.js claimer-DM
path) was rejected as a transfer target. Switch to isStaff() so the
transfer-target gate matches the rest of the codebase's staff
definition.

Also:
- Reject bots as transfer targets (guildMember.user.bot).
- Reject self-transfer (transferring to interaction.user.id) — the
  rename + DB write would no-op but the log line claimed a transfer
  that didn't happen.
- Resolve the target member cache-first to avoid an unnecessary REST
  round-trip when the GuildMembers intent has the user cached.
2026-05-24 05:04:40 +00:00
3212004fc9 /transfer: rename the channel + fix 10062 Unknown interaction errors
Two real bugs in handleTransfer plus a class issue across all the
channel-mod handlers.

/transfer didn't rename
  handleTransfer set claimedBy but never called enqueueRename, so the
  channel name stayed at whatever the previous claimer left it as.
  /claim (applyClaim in handlers/buttons.js) does the rename via
  makeTicketName + STAFF_EMOJIS; /transfer now does the same, plus
  writes claimerId (was only writing claimedBy). Uses
  'escalated-claimed' state when tier >= 1, 'claimed' otherwise.

DiscordAPIError 10062 (Unknown interaction)
  handleAdd / handleRemove / handleTransfer / handleMove / handleTopic
  all called interaction.reply() at the end after awaiting one or more
  channelQueue ops. Those ops serialize behind any pending rename or
  move on the same channel — easily exceeding Discord's 3s interaction-
  token window. The reply then 404s with code 10062. Production logs
  showed handleRemove failing this way (the visible 'Remove user
  error: DiscordAPIError[10062]' lines); transfer had the same pattern.

Switch each handler to deferReply() up front + editReply() at the end
+ editReply() in the catch (with .catch(() => {}) to swallow the rare
case where even the deferred reply context is gone).

handleTransfer keeps the up-front isStaff role check as a reply()
because that path is synchronous and the token is fresh.
2026-05-24 05:02:59 +00:00
a565450e2d buttons: allow non-staff to close tickets (countdown still applies)
After the previous TICKET_BUTTON_HANDLERS gate, ticket creators and
/add'd members were locked out of every ticket button — including
close_ticket on their own ticket. Add a PUBLIC_TICKET_BUTTONS set so
the close flow (close_ticket / confirm_close / confirm_close_with_email
/ confirm_close_no_email / cancel_close) skips the staff check.

Claim, escalate, and de-escalate remain staff-only. The 60s
FORCE_CLOSE_TIMER countdown, the transcript archive, and the optional
customer-closure email all continue to fire on the existing
runFinalClose path — nothing about the close behavior changes, only
who is allowed to click the button.

cancel_close is intentionally public too: anyone in the channel can
abort a pending close, including the original setter, staff, or the
creator. The pendingCloses entry stores who set it, but the abort path
doesn't gate on that — kept permissive to match the rest of the close
flow.
2026-05-19 22:15:38 +00:00
837fd10984 escalation: drop dead 'reason' param — never populated, always logged as null
The /escalate slash command never had a reason option in its definition
(commands/register.js only takes a 'level' option), so handleEscalate
hardcoded reason=null. The escalate button path passed null explicitly.
The log line wrote it verbatim as "Reason: null" on every escalate.

Remove the dead surface:
- runEscalation signature drops the reason parameter.
- The customer-facing email body drops the conditional reason suffix
  (`reason ? `\n\nReason: ${reason}` : ''`) — always-false branch.
- The logging-channel post drops "\nReason: ${reason}".
- handleEscalate drops the `const reason = null;` line and the call-site arg.
- handleEscalateButton (handlers/buttons.js) drops the trailing `null` arg.

If we ever want to capture a reason, the slash command would need a
StringOption('reason') and an escalate-modal for the button path —
neither exists today.
2026-05-19 20:20:03 +00:00
28 changed files with 1451 additions and 326 deletions

View File

@@ -60,8 +60,10 @@ SUPPORT_NAME=Support
LOGO_URL= # URL of logo shown in embeds (optional) LOGO_URL= # URL of logo shown in embeds (optional)
EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks) EMAIL_SIGNATURE= # HTML signature for outgoing emails (use \n for line breaks)
TICKET_CLOSE_SUBJECT_PREFIX=[Resolved] TICKET_CLOSE_SUBJECT_PREFIX=[Resolved]
# Email tickets only (closure email body): # Email tickets only (closure email body). Placeholders: {closer_name}; \n for line breaks.
TICKET_CLOSE_MESSAGE= # Body of closure email to customer TICKET_CLOSE_MESSAGE= # Body of closure email to customer
# Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}; \n for line breaks.
TICKET_ESCALATION_EMAIL_MESSAGE= # Body of escalation email to customer
TICKET_CLOSE_SIGNATURE= # Signature on closure email TICKET_CLOSE_SIGNATURE= # Signature on closure email
# Discord ticket closure (in-channel before transcript, transcript post, and auto-close): # Discord ticket closure (in-channel before transcript, transcript post, and auto-close):
DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.) DISCORD_CLOSE_MESSAGE= # Message in ticket channel before transcript (e.g. ... If you still need assistance, please open a new ticket.)
@@ -103,6 +105,13 @@ ALLOW_CLAIM_OVERWRITE=false
ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs) ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs)
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60) FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30) GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
GMAIL_POLL_ENABLED= # Inbound email flow master switch; "false" disables polling (default on). Toggle at runtime with /email on|off
GMAIL_LABEL_TRIAGE= # Gmail label for newly created tickets (default "Triage"); auto-created if missing
GMAIL_LABEL_ESCALATED= # Gmail label for escalated tickets (default "Escalated")
GMAIL_LABEL_RESOLVED= # Gmail label for resolved/closed tickets (default "Resolved")
GMAIL_LABEL_FOR_JAKE= # /folder option (default "For Jake")
GMAIL_LABEL_DASHBOARD_ERRORS= # /folder option (default "Dashboard Errors")
GMAIL_LABEL_PARTNERSHIP_OFFERS= # /folder option (default "Partnership Offers")
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs AUTOMATION_LOG_CHANNEL_ID= # Channel for auto-close/auto-unclaim/reminder logs
RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue logs

View File

@@ -3,7 +3,7 @@
Broccolini Bot is a single Node.js process. It does three things at once: Broccolini Bot is a single Node.js process. It does three things at once:
1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages. 1. **Listens to Discord** — slash commands, button clicks, modals, ticket-channel messages.
2. **Polls Gmail** — every N seconds, pulls unread `category:primary` mail and turns each thread into a Discord ticket channel. 2. **Polls Gmail** — every N seconds, pulls unread `in:inbox` mail and turns each thread into a Discord ticket channel.
3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API. 3. **Serves a couple of HTTP endpoints** — a public healthcheck and an internal config/control API.
State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API. State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API.
@@ -27,7 +27,7 @@ Every `setInterval` in the `ready` block is wrapped through `trackInterval(...)`
`gmail-poll.js`: `gmail-poll.js`:
1. Lists unread messages in `category:primary`. 1. Lists unread messages in `in:inbox` (excludes Spam, Trash, and anything filter-skipped from the inbox).
2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document. 2. For each thread, looks up an existing `Ticket` by `gmailThreadId`. If none, creates a Discord channel under `TICKET_CATEGORY_ID` (or an overflow category if the main is full at Discord's 50-channel limit) and inserts a `Ticket` document.
3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`. 3. Posts a welcome embed + action row (Close / Claim / Escalate) into the channel and pings `ROLE_ID_TO_PING`.
4. On subsequent emails in the same thread, just appends the new message to the existing channel. 4. On subsequent emails in the same thread, just appends the new message to the existing channel.

View File

@@ -79,8 +79,7 @@ const client = new Client({
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers
GatewayIntentBits.GuildPresences // Required for staff presence detection; enable in Discord Developer Portal
], ],
partials: [Partials.Channel] partials: [Partials.Channel]
}); });
@@ -208,8 +207,12 @@ client.once('ready', async () => {
registerCommands().catch(console.error); registerCommands().catch(console.error);
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS)); if (CONFIG.GMAIL_POLL_ENABLED) {
poll(client); gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
poll(client);
} else {
console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false) — inbox will not be polled. Enable with /email on.');
}
if (CONFIG.AUTO_CLOSE_ENABLED) { if (CONFIG.AUTO_CLOSE_ENABLED) {
trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000)); trackInterval(setInterval(() => checkAutoClose(client, sendTicketClosedEmail), 60 * 60 * 1000));
@@ -238,15 +241,6 @@ client.once('ready', async () => {
client.login(CONFIG.DISCORD_TOKEN); client.login(CONFIG.DISCORD_TOKEN);
const app = express(); const app = express();
app.use(express.json());
// Reject API traffic with 503 until ready event has fired and routes are mounted.
// (appReady is declared at module top so the ready callback can flip it.)
app.use((req, res, next) => {
if (!appReady && req.path.startsWith('/api')) {
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
}
next();
});
app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting')); app.get('/', (req, res) => res.send(appReady ? 'Active' : 'Starting'));
// app.listen is called inside client.once('ready') after MongoDB connects and routes mount. // app.listen is called inside client.once('ready') after MongoDB connects and routes mount.

View File

@@ -368,6 +368,41 @@ async function registerCommands() {
) )
), ),
new SlashCommandBuilder()
.setName('email')
.setDescription('Turn the inbound email flow (Gmail polling) on or off, or check its status')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand(sub =>
sub.setName('on').setDescription('Start polling the inbox and creating tickets from email')
)
.addSubcommand(sub =>
sub.setName('off').setDescription('Stop polling the inbox (outbound emails still send)')
)
.addSubcommand(sub =>
sub.setName('status').setDescription('Show whether the inbound email flow is on or off')
),
new SlashCommandBuilder()
.setName('folder')
.setDescription("Move this ticket's email thread into a Gmail folder")
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('destination')
.setDescription('Target folder')
.setRequired(true)
.addChoices(
{ name: 'For Jake', value: 'FOR_JAKE' },
{ name: 'Spam', value: 'SPAM' },
{ name: 'Dashboard Errors', value: 'DASHBOARD_ERRORS' },
{ name: 'Partnership Offers', value: 'PARTNERSHIP_OFFERS' }
)
),
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('cancel-close') .setName('cancel-close')
.setDescription('Cancel a pending force-close countdown') .setDescription('Cancel a pending force-close countdown')

View File

@@ -17,7 +17,6 @@ const CONFIG = {
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets', TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID, DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING, ROLE_ID_TO_PING: process.env.ROLE_ID_TO_PING,
ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID, TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID, LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
@@ -30,7 +29,6 @@ const CONFIG = {
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫', CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000), PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
GAME_LIST: process.env.GAME_LIST || '', GAME_LIST: process.env.GAME_LIST || '',
// Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming). // Tier 2/3 escalation: category IDs where ticket channels are placed (env uses *_CHANNEL_* for legacy naming).
EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null, EMAIL_ESCALATED2_CHANNEL_ID: process.env.EMAIL_ESCALATED2_CHANNEL_ID || null,
@@ -38,9 +36,11 @@ const CONFIG = {
EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null, EMAIL_ESCALATED3_CHANNEL_ID: process.env.EMAIL_ESCALATED3_CHANNEL_ID || null,
DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null, DISCORD_ESCALATED3_CHANNEL_ID: process.env.DISCORD_ESCALATED3_CHANNEL_ID || null,
ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.', ESCALATION_MESSAGE: process.env.ESCALATION_MESSAGE || 'Your ticket has been escalated.\n\nA senior {support_name} will be here to assist as soon as possible.',
// Email tickets only (escalation notification email body). Placeholders: {escalator_name}, {tier}.
TICKET_ESCALATION_EMAIL_MESSAGE: process.env.TICKET_ESCALATION_EMAIL_MESSAGE || '{escalator_name} escalated this ticket to {tier}.',
TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]', TICKET_CLOSE_SUBJECT_PREFIX: process.env.TICKET_CLOSE_SUBJECT_PREFIX || '[Resolved]',
// Email tickets only (closure email body): // Email tickets only (closure email body):
TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || 'This ticket has been marked as resolved. If you would like to re-open this issue, please reply to this email.', TICKET_CLOSE_MESSAGE: process.env.TICKET_CLOSE_MESSAGE || '{closer_name} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.',
TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.', TICKET_CLOSE_SIGNATURE: process.env.TICKET_CLOSE_SIGNATURE || 'Thank you for using Indifferent Broccoli.',
// Discord ticket closure (in-channel and transcript): // Discord ticket closure (in-channel and transcript):
DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.', DISCORD_CLOSE_MESSAGE: process.env.DISCORD_CLOSE_MESSAGE || 'This ticket has been closed. A transcript has been saved. If you still need assistance, please open a new ticket.',
@@ -56,9 +56,6 @@ const CONFIG = {
TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.", TICKET_WELCOME_MESSAGE: process.env.TICKET_WELCOME_MESSAGE || "We got your ticket. We'll be with you as soon as possible. Feel free to add any additional information to your ticket.",
TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀', TICKET_CLAIMED_MESSAGE: process.env.TICKET_CLAIMED_MESSAGE || 'Ticket claimed by {staff_mention} 🚀',
TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️', TICKET_UNCLAIMED_MESSAGE: process.env.TICKET_UNCLAIMED_MESSAGE || 'Ticket unclaimed by {staff_mention} ☀️',
REMINDER_ENABLED: process.env.REMINDER_ENABLED === 'true',
REMINDER_AFTER_HOURS: toInt(process.env.REMINDER_AFTER_HOURS, 24),
REMINDER_MESSAGE: process.env.REMINDER_MESSAGE || 'Hey {ping}! This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.',
PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true', PRIORITY_ENABLED: process.env.PRIORITY_ENABLED === 'true',
DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal', DEFAULT_PRIORITY: process.env.DEFAULT_PRIORITY || 'normal',
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴', PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
@@ -80,7 +77,16 @@ const CONFIG = {
ADMIN_ID: process.env.ADMIN_ID || null, ADMIN_ID: process.env.ADMIN_ID || null,
FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60), FORCE_CLOSE_TIMER: toInt(process.env.FORCE_CLOSE_TIMER_SECONDS, 60),
GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000, GMAIL_POLL_INTERVAL_MS: toInt(process.env.GMAIL_POLL_INTERVAL_SECONDS, 30) * 1000,
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null, // Inbound email flow master switch. Absent/anything-but-"false" => on, so
// existing deployments keep polling with no .env change. Toggle via /email.
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
// Gmail "folder" (label) names for ticket-lifecycle routing — see services/gmailLabels.js.
GMAIL_LABEL_TRIAGE: process.env.GMAIL_LABEL_TRIAGE || 'Triage',
GMAIL_LABEL_ESCALATED: process.env.GMAIL_LABEL_ESCALATED || 'Escalated',
GMAIL_LABEL_RESOLVED: process.env.GMAIL_LABEL_RESOLVED || 'Resolved',
GMAIL_LABEL_FOR_JAKE: process.env.GMAIL_LABEL_FOR_JAKE || 'For Jake',
GMAIL_LABEL_DASHBOARD_ERRORS: process.env.GMAIL_LABEL_DASHBOARD_ERRORS || 'Dashboard Errors',
GMAIL_LABEL_PARTNERSHIP_OFFERS: process.env.GMAIL_LABEL_PARTNERSHIP_OFFERS || 'Partnership Offers',
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true', STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion', STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true', STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
@@ -89,9 +95,6 @@ const CONFIG = {
PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true', PIN_ESCALATION_MESSAGE_ENABLED: process.env.PIN_ESCALATION_MESSAGE_ENABLED === 'true',
TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true', TRANSCRIPT_DM_TO_CREATOR: process.env.TRANSCRIPT_DM_TO_CREATOR === 'true',
PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true', PIN_SUPPRESS_SYSTEM_MESSAGE: process.env.PIN_SUPPRESS_SYSTEM_MESSAGE === 'true',
SETTINGS_PORT: toInt(process.env.SETTINGS_PORT, 12752),
SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753), INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null
}; };

View File

@@ -0,0 +1,124 @@
# Email Flow Toggle — Design
**Date:** 2026-06-03
**Status:** Approved (design); pending implementation plan
## Goal
A staff slash command to turn the **inbound** email flow on and off at runtime, with the state surviving container restarts.
- **ON** — Gmail polling runs as today: reads the inbox, creates/append ticket channels, runs all downstream features.
- **OFF** — Polling stops entirely. The mailbox is **never read** (inbox untouched). No new tickets are created from email.
- **Outbound is unaffected** in both states — ticket-close emails, Gmail replies, and notification emails still send when staff act on existing tickets. (Decision: "off" scopes to inbound polling only.)
- **Persists across restarts** — the off-state is honored on the next boot. (Decision: persisted, not runtime-only.)
Discord-originated tickets are independent of email polling and are unaffected by this toggle.
## Decisions (locked)
| Decision | Choice |
|----------|--------|
| Scope of OFF | Inbound polling only; outbound email still sends |
| Persistence | Persist to `.env` via existing config-persistence path; honored on boot |
| Command shape | New dedicated `/email on\|off\|status` command (Approach A) |
| Existing `/gmailpoll` | Guarded so it cannot silently re-enable polling while flow is OFF |
Rejected: folding into `/gmailpoll` subcommands (needless churn to a working command); sentinel interval `/gmailpoll 0` (poor discoverability).
## Architecture
Single source of truth: a new boolean config flag `GMAIL_POLL_ENABLED` (default **true**). The live poll timer (`gmailPollInterval` in `broccolini-discord.js`) is started/stopped to match the flag.
### 1. Config flag — `GMAIL_POLL_ENABLED`
- **`config.js`** — add:
```js
GMAIL_POLL_ENABLED: process.env.GMAIL_POLL_ENABLED !== 'false',
```
Undefined/absent → `true`, so existing deployments keep polling with no `.env` change required.
- **`services/configSchema.js`** — add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS`. The existing `/ENABLED$/` rule in `inferType()` already classifies it as a boolean validator, so the settings site can also toggle it (bonus, no extra work).
### 2. Boot gate
In `broccolini-discord.js` (`client.once('ready')`, currently ~lines 210-211), only start the poll when enabled:
```js
if (CONFIG.GMAIL_POLL_ENABLED) {
gmailPollInterval = trackInterval(setInterval(() => poll(client), CONFIG.GMAIL_POLL_INTERVAL_MS));
poll(client);
} else {
console.log('Gmail poll disabled by config (GMAIL_POLL_ENABLED=false)');
}
```
When disabled, `gmailPollInterval` stays `null` — no timer is registered in `activeIntervals`, nothing reads the inbox.
### 3. `/email` command
- **Registration** — `commands/register.js`: a `SlashCommandBuilder` named `email` with three subcommands (`on`, `off`, `status`), `setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)` to match sibling commands.
- **Dispatch** — add `email: handleEmail` to `COMMAND_HANDLERS` in `handlers/commands/index.js`. Staff-gated automatically via `requireStaffRole()` at the dispatcher entry.
- **Handler** — `handleEmail(interaction)`:
- `on`:
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: true })` (updates runtime `CONFIG` **and** writes `.env`).
2. Clear the auth-suspend latch via `require('../../gmail-poll').setPollSuspended(false)` so a prior `invalid_grant` suspend doesn't keep polling dead. If auth is still broken, the next cycle re-suspends and DMs admin, exactly as today.
3. `setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS)` to start the live timer.
4. Reply (ephemeral): "Email flow is now **on**."
- `off`:
1. `applyConfigUpdates({ GMAIL_POLL_ENABLED: false })`.
2. `clearGmailPollInterval()`.
3. Reply (ephemeral): "Email flow is now **off** — the inbox will not be polled. Outbound emails still send."
- `status`:
- Report `CONFIG.GMAIL_POLL_ENABLED`, the current interval (`CONFIG.GMAIL_POLL_INTERVAL_MS / 1000`s), and whether polling is currently suspended by an auth error.
- On `on`/`off`, fire `logTicketEvent('Email flow toggled', [...], interaction).catch(() => {})` — fire-and-forget, matching `/gmailpoll`.
`applyConfigUpdates` is called in-process (the command runs inside the bot), reusing the same path the internal API uses — no HTTP round-trip.
### 4. Guard `/gmailpoll` against silent re-enable
`handleGmailPoll` currently calls `setGmailPollInterval(ms)`, which *starts* the timer. While flow is OFF that would silently re-enable polling. Change it so that when `CONFIG.GMAIL_POLL_ENABLED` is false:
- update the interval in memory only (`CONFIG.GMAIL_POLL_INTERVAL_MS = ms`) — matching `/gmailpoll`'s existing runtime-only model (it has never persisted to `.env`), but
- do **not** start the live timer, and
- reply: "Interval saved (`<n>`s), but the inbound email flow is currently **off** — it will apply when you run `/email on`."
When flow is ON, `/gmailpoll` behaves exactly as today.
## Data flow
```
/email off ──> applyConfigUpdates({GMAIL_POLL_ENABLED:false}) ──> CONFIG updated + .env written
└─> clearGmailPollInterval() ──> live timer stopped, gmailPollInterval=null
(no inbox reads)
restart ──> config.js reads GMAIL_POLL_ENABLED=false ──> ready gate skips poll start ──> stays off
/email on ──> applyConfigUpdates({GMAIL_POLL_ENABLED:true}) ──> CONFIG updated + .env written
├─> setPollSuspended(false) ──> clear prior auth-suspend latch
└─> setGmailPollInterval(interval) ──> live timer started, immediate poll
```
## Error handling
- Command runs through `runHandler`, so any throw is logged and the user gets an ephemeral "Something went wrong."
- `applyConfigUpdates` returns `{ applied, errors }`; if `GMAIL_POLL_ENABLED` lands in `errors` (should not, given the boolean validator), reply with the error rather than claiming success, and do not flip the live timer.
- Logging stays fire-and-forget (`.catch(() => {})`), per Hard Rule 4.
## Files touched
| File | Change |
|------|--------|
| `config.js` | Add `GMAIL_POLL_ENABLED` (default true) |
| `services/configSchema.js` | Add `'GMAIL_POLL_ENABLED'` to `ALLOWED_CONFIG_KEYS` |
| `broccolini-discord.js` | Gate poll start in `ready` on the flag |
| `commands/register.js` | Register `/email on\|off\|status` |
| `handlers/commands/index.js` | Add `handleEmail`; guard `handleGmailPoll` |
| `.env.example` | Document `GMAIL_POLL_ENABLED` (optional, default true) |
No DB schema changes. No destructive data ops. The mailbox is never read while OFF and is never written by this feature.
## Verification
- `/email off` → logs show no further poll cycles; `.env` contains `GMAIL_POLL_ENABLED=false`.
- Restart container → no polling on boot; `/email status` reports off.
- `/email on` → poll resumes (immediate cycle), `.env` flips to `true`.
- While OFF, `/gmailpoll 60` → interval saved, no polling starts.
- `npm test` (covers `services/configSchema.js`); `node --check` on every edited file.

View File

@@ -0,0 +1,217 @@
# Gmail Folder Routing — Design
**Date:** 2026-06-03
**Status:** Approved (design); pending implementation
## Goal
Route a ticket's Gmail thread into Gmail "folders" (labels) as the ticket moves
through its lifecycle, plus a manual `/folder` command for ad-hoc filing.
- On **ticket creation**, the source email thread goes into a **Triage** folder
(instead of the current plain archive).
- On **escalation**, the thread moves to an **Escalated** folder.
- On **resolution** (close), the thread moves to a **Resolved** folder.
- A **`/folder`** slash command lets staff move the current ticket's thread to one
of four manual folders: **For Jake**, **Spam**, **Dashboard Errors**,
**Partnership Offers**.
Discord-originated tickets (`gmailThreadId` prefixed `discord-`) have no Gmail
thread and are untouched by all of the above.
## Decisions (locked)
| Decision | Choice |
|----------|--------|
| Folder semantics | **Exclusive** — moving to a folder removes every other managed label and drops the thread out of the Inbox. A thread lives in exactly one managed folder. |
| "Spam" target | Gmail's **built-in system `SPAM`** label (trains the filter, hides from normal views). |
| Label names | **Configurable via `.env`**, defaulting to the names above. |
| Missing labels | **Auto-created** on first use (idempotent, cached). The system `SPAM` label is never created. |
| `/folder` options | Exactly the **4 manual folders**. Triage/Escalated/Resolved are lifecycle-driven only, not manually selectable. |
| De-escalation | **Leaves the folder as Escalated** — no auto-move back. |
Gmail labels are additive by nature; "exclusive folder" behavior is synthesized by
always removing the other managed labels on every move (removing an absent label is
a no-op, so this is safe and idempotent).
## Architecture
### 1. New module — `services/gmailLabels.js`
Single home for all label logic. Folders defined by logical key:
| Key | Source | Default name |
|-----|--------|--------------|
| `TRIAGE` | `CONFIG.GMAIL_LABEL_TRIAGE` (`.env GMAIL_LABEL_TRIAGE`) | `Triage` |
| `ESCALATED` | `CONFIG.GMAIL_LABEL_ESCALATED` | `Escalated` |
| `RESOLVED` | `CONFIG.GMAIL_LABEL_RESOLVED` | `Resolved` |
| `FOR_JAKE` | `CONFIG.GMAIL_LABEL_FOR_JAKE` | `For Jake` |
| `DASHBOARD_ERRORS` | `CONFIG.GMAIL_LABEL_DASHBOARD_ERRORS` | `Dashboard Errors` |
| `PARTNERSHIP_OFFERS` | `CONFIG.GMAIL_LABEL_PARTNERSHIP_OFFERS` | `Partnership Offers` |
| `SPAM` | built-in system label `SPAM` | (not configurable) |
`MANAGED_USER_KEYS` = all keys except `SPAM` (these are the user labels whose IDs
get resolved/created and which participate in the remove-others set).
**Exports:**
- `moveThreadToFolder(threadId, folderKey, gmail = getGmailClient())` — the one
operation everything calls.
1. Resolve the target label ID (`resolveLabelId`), and the IDs of all managed
user labels (to build the remove set).
2. `addLabelIds = [targetId]`.
3. `removeLabelIds = [all managed user-label IDs except target] + ['INBOX', 'UNREAD']`
(computed by `computeLabelMutation`). For `SPAM` target, the user labels are
all removed and `SPAM` is added; `INBOX`/`UNREAD` removed as usual.
4. `await gmail.users.threads.modify({ userId: 'me', id: threadId, requestBody: { addLabelIds, removeLabelIds } })`.
- On a `400` "invalid label" (stale cached ID for a label deleted in Gmail),
clear the cache and retry once.
- `resolveLabelId(gmail, key)` — returns the Gmail label ID for a key.
- `SPAM` short-circuits to `'SPAM'`.
- Otherwise: check the module-scoped name→ID cache; on miss, `users.labels.list`
and match by name (case-sensitive, Gmail's behavior); if still absent,
`users.labels.create` it (`labelListVisibility: 'labelShow'`,
`messageListVisibility: 'show'`) and cache the new ID.
- `computeLabelMutation(targetKey, idByKey)`**pure** function returning
`{ addLabelIds, removeLabelIds }`. Split out for unit testing without the network.
**Caching:** module-scoped `Map` of label-name → ID, populated lazily. Cleared and
re-fetched on a stale-label error.
**Client:** `getGmailClient` is required from `services/gmail.js` (acyclic —
`gmail.js` does not depend on `gmailLabels.js`). Callers that already hold a client
(the poll loop) pass it in; others let the default create one.
### 2. Triage on ticket creation — `gmail-poll.js`
Today every processed message hits `markGmailMessageRead` (strips `INBOX`+`UNREAD`)
at the shared bottom of the per-message loop (~line 397). Restructure so the
archive action is branch-specific:
- **New ticket created** (and the **reopened** closed→open case, which runs in the
create branch) → `await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail)`.
- **Follow-up to an existing open ticket** (the `if (ticketChan)` append branch) →
keep `markGmailMessageRead(gmail, msgRef)`. A reply on a thread already filed
under "For Jake"/"Resolved" should not be dragged back to Triage automatically.
- **Self / limit-exceeded / create-failure** early-`continue` paths → unchanged
plain archive (they already call `markGmailMessageRead` before `continue`).
The shared bottom `markGmailMessageRead` call is removed; the two surviving paths
(append, create) each archive/move explicitly.
`moveThreadToFolder` on creation is awaited inside the existing try/catch; a failure
is logged via the poll's existing error handling and does not abort the loop.
### 3. Escalated hook — `handlers/commands/escalation.js`
`runEscalation` is shared by the `/escalate` slash command and the tier-pick
buttons (single hook site). Inside the existing
`if (!isDiscordTicket && ticket.gmailThreadId)` block (where the escalation
notification email is already sent), add:
```js
moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED')
.catch(err => logError('gmailLabels: escalate move', err).catch(() => {}));
```
Non-fatal — a label failure never blocks the escalation. De-escalation
(`runDeescalation`) is **not** modified.
### 4. Resolved hook — two close finalizers
Both finalizers set `status: 'closed'` and remain separate:
- `handlers/commands/close.js``finalizeForceClose`
- `handlers/buttons.js``runFinalClose`
In each, for non-Discord tickets (`!ticket.gmailThreadId.startsWith('discord-')`),
after the status update, add a non-fatal:
```js
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
```
One added line per finalizer. The move runs regardless of whether a close email is
sent (so close-without-email still files the thread under Resolved).
### 5. `/folder` command
- **Registration** (`commands/register.js`): `SlashCommandBuilder` named `folder`,
`setDefaultMemberPermissions(ManageMessages)`, Guild context / GuildInstall, with
a required string option `destination` and choices:
- `For Jake``FOR_JAKE`
- `Spam``SPAM`
- `Dashboard Errors``DASHBOARD_ERRORS`
- `Partnership Offers``PARTNERSHIP_OFFERS`
- **Dispatch** (`handlers/commands/index.js`): add `folder: handleFolder` to
`COMMAND_HANDLERS`; add a `/folder` line to `/help`.
- **Handler** `handleFolder(interaction)`:
1. `findTicketForChannel(interaction)`; bail if none.
2. If `ticket.gmailThreadId.startsWith('discord-')` → ephemeral
"This ticket has no email thread, so it can't be moved to a Gmail folder."
3. Otherwise `await moveThreadToFolder(ticket.gmailThreadId, folderKey)`.
4. Ephemeral reply: "Moved this ticket's email thread to **<label>**."
5. `logTicketEvent('Email thread filed', [...], interaction).catch(() => {})`.
6. On error, ephemeral "Failed to move the email thread: <reason>."
### 6. Config & docs
- `config.js`: add the six `GMAIL_LABEL_*` keys with the default names above.
- `.env.example`: document the six vars (default-on naming).
- Not added to `ALLOWED_CONFIG_KEYS` — settings-site contract unchanged.
## Data flow
```
inbound email (poll, flow ON)
└─ new ticket ──> moveThreadToFolder(thread, TRIAGE) [add Triage; remove others+INBOX+UNREAD]
└─ follow-up ──> markGmailMessageRead(msg) [remove INBOX+UNREAD on the new msg only]
/escalate or tier button ──> runEscalation ──> moveThreadToFolder(thread, ESCALATED)
close (slash or button) ──> finalize ──> moveThreadToFolder(thread, RESOLVED)
/folder <dest> ──> handleFolder ──> moveThreadToFolder(thread, <dest|SPAM>)
```
Every `moveThreadToFolder` resolves IDs (creating missing user labels), then one
`threads.modify` enforcing exclusive-folder semantics.
## Error handling
- Lifecycle hooks (Triage/Escalated/Resolved) are non-fatal `.catch` — Gmail
problems never block ticket flow. Errors logged via `logError`.
- `/folder` surfaces failures to the invoking staffer ephemerally.
- Stale cached label ID → one cache-clear + retry inside `moveThreadToFolder`.
- Label operations are independent of `CONFIG.GMAIL_POLL_ENABLED` (the `/email`
toggle): they are explicit staff/lifecycle actions, not polling. Triage-on-create
only fires during polling, so it is naturally inert while the flow is off.
## Files touched
| File | Change |
|------|--------|
| `services/gmailLabels.js` | **New** — folder defs, `moveThreadToFolder`, `resolveLabelId`, `computeLabelMutation`, cache |
| `tests/gmailLabels.test.js` | **New** — unit tests for mutation logic + label resolution |
| `config.js` | Add six `GMAIL_LABEL_*` config keys (defaults) |
| `.env.example` | Document the six label-name vars |
| `gmail-poll.js` | Triage on create/reopen; keep plain archive for follow-ups & non-ticket paths |
| `handlers/commands/escalation.js` | `runEscalation`: move thread to Escalated (non-fatal) |
| `handlers/commands/close.js` | `finalizeForceClose`: move thread to Resolved (non-fatal) |
| `handlers/buttons.js` | `runFinalClose`: move thread to Resolved (non-fatal) |
| `commands/register.js` | Register `/folder` with 4 choices |
| `handlers/commands/index.js` | `handleFolder` + dispatch entry + `/help` line |
No DB schema changes. No destructive data ops — `threads.modify` only relabels;
nothing is deleted or trashed. (Moving to `SPAM` is reversible from Gmail.)
## Verification
- `npm test` — existing suite plus new `tests/gmailLabels.test.js`
(`computeLabelMutation` exclusivity; `resolveLabelId` cache-hit / create-on-miss /
SPAM short-circuit, with a fake gmail client).
- `node --check` on every edited file.
- Manual (post-deploy): create an email ticket → its thread lands in Triage and
leaves the inbox; `/escalate` → Escalated; `/folder For Jake` → For Jake (and out
of Escalated); close → Resolved. Discord ticket → `/folder` reports no email
thread.

View File

@@ -0,0 +1,176 @@
# Per-Staff Metrics & Ticket Analytics — Design
**Date:** 2026-06-04
**Status:** Approved (design); implementation pending
**Component:** broccolini-bot
## Goal
Capture per-staff and per-ticket activity as a durable, event-sourced log, and
expose a useful subset via a `/stats` command. The event log is the foundation
for a future analytics dashboard on the tickets website (graphs, timing
analyses, busiest-time heatmaps, per-game and per-emailer reporting).
**Principle: collect rich data now** (history cannot be backfilled); `/stats` v1
surfaces the count metrics; timing/temporal metrics are collected but displayed
on the website later.
Because it is event-sourced, interpretation questions (does a reopen revoke
resolution credit? does time-open reset?) do **not** need a single baked-in
answer — events are recorded with timestamps and the query layer decides.
## Metrics
### Counts (shown in `/stats` v1, per staff member)
- **Claims**; **claims while escalated, by tier** (`claim` events with `tier > 0`)
- **Closes** performed by the staff member
- **Resolved (credit)** — closes where the staff member was the **claimer**
- **Escalations** / **De-escalations**, by tier
- **Unclaimed-at-close** — closes where the ticket had no claimer
- **Transfers** (initiated / received); **Reopens** (rate)
- Every count sliceable **email vs discord** and by **priority**
### Ticket volume & temporal (derived from `Ticket` collection)
- Total tickets, **email vs discord**, over a window.
- **Busiest times** — distribution of `createdAt` by hour-of-day and day-of-week
(and `closedAt` similarly). Stored as full UTC datetimes; website buckets/TZ-adjusts.
### Timing (collected now; website-only display in v1)
Derived from event timestamps + `ticket.createdAt` / `closedAt`:
- time to first staff response; time to claim; time open (created → closed)
- time to escalation; time to first response & to claim after escalation
- time to close after escalation; total time open across reopen cycles
## Ticket source & tier
- **Source:** `discord` if `gmailThreadId` starts with `discord-`/`discord-msg-`, else `email`. Denormalized as `ticketType` on every event.
- **Requester:** email tickets are attributed to `senderEmail`; discord tickets to the creator's `creatorId`. Both denormalized onto events so per-emailer (email) and per-user (discord) reporting work, sliceable by source.
- **Tier** (existing convention): `0` normal, `1` → "Tier 2 Support", `2` → "Tier 3 Support". Events store raw numeric `tier`; `/stats` mirrors the labels.
## Data model
### New model `StaffAction` (event log, in `models.js`)
```
{
staffId: String, // actor's Discord user ID ('system' for automated)
type: String, // 'claim'|'response'|'escalate'|'deescalate'|'close'|'reopen'|'transfer'
tier: Number, // ticket escalationTier at the moment (0 if none)
ticketType: String, // 'email' | 'discord'
priority: String, // ticket priority at the moment
game: String, // detected game (denormalized)
senderEmail: String, // requester for EMAIL tickets (denormalized)
creatorId: String, // requester for DISCORD tickets — creator's user ID (denormalized)
gmailThreadId: String, // ticket linkage / per-ticket timeline join key
guildId: String,
createdAt: Date, // default: Date.now (function reference)
// close-only:
closerType: String, // 'staff' | 'user' | 'system'
resolverId: String, // ticket.claimerId at close (credit); null if unclaimed
wasClaimed: Boolean, // false → unclaimed-at-close
// transfer-only:
fromId: String, // previous claimer
toId: String // new claimer
}
```
For `close`, `staffId` = the closer, `resolverId` = the claimer credited.
For `transfer`, `staffId` = who ran `/transfer`.
For `reopen`, `staffId` = `'system'` (customer email reply re-opens the thread).
Indexes: `{ staffId: 1, createdAt: -1 }` and `{ gmailThreadId: 1, createdAt: 1 }`.
### `Ticket` schema changes
- `game: String` — set at creation from existing `detectGame(subject, rawBody)`.
- `closedAt: Date` — set when a ticket is closed (robust source for time-open /
busiest-close-times even if events are pruned).
## Recording
New `services/staffStats.js``recordAction(staffId, type, payload)`,
fire-and-forget (`.catch(() => {})`), never blocking the action. `ticketType`,
`priority`, `game`, `senderEmail` read from the ticket being acted on.
**Idempotency (correctness requirement):** record an event **only on a successful
state transition** — a claim that actually set the claimer, a close that actually
closed, an escalate that changed tier. No-op / rejected / double-click
interactions must not produce events.
| Event | Site | Notes |
|--------------|-------------------------------------------------------|-------|
| `claim` | `handlers/buttons.js` `handleClaimButton` | only if claim succeeds; `tier` = current |
| `response` | `handlers/messages.js` `handleDiscordReply` | only when author `isStaff`; email & discord; before the email-only early return |
| `escalate` | `handlers/commands/escalation.js` `runEscalation` | `tier` = new tier |
| `deescalate` | `handlers/commands/escalation.js` `runDeescalation` | `tier` = new tier |
| `transfer` | `handlers/commands/index.js` `handleTransfer` | `fromId` = old claimer, `toId` = target, actor = runner |
| `close` | `handlers/buttons.js` `runFinalClose` | sets `closerType`/`resolverId`/`wasClaimed`; also set `ticket.closedAt` |
| `close` | `handlers/commands/close.js` `finalizeForceClose` | capture closer ID via `pendingCloses` (store `interaction.user.id` at countdown start) |
| `reopen` | `gmail-poll.js` reopen path (`existing.status === 'closed'`) | `staffId='system'`, customer-driven |
Auto-close (`services/tickets.js`) and orphan-channel reconcile / auto-unclaim
closes → `close` with `closerType: 'system'`, `staffId: 'system'`, preserving
`resolverId`/`wasClaimed` so resolution credit and unclaimed-at-close still count
without attributing a human.
`response` volume: record each staff reply (low volume); "first response" =
`min(createdAt)`; "after escalation" = first `response` after the `escalate` event.
## `/stats` command
Registered in `commands/register.js`; handler in the commands layer.
- `period` — string, **autocomplete + free text**, default **30 days**. Suggestions:
`7 days`, `30 days`, `3 months`, `6 months`, `1 year`. Parses `<n>d/w/m/mo/y` and
bare number = days; unparseable → 30 days.
- `member` — optional user.
- `source` — optional choice: **all** (default) / **email** / **discord**. Filters
every metric by `ticketType`, so the same command shows combined stats or a
single channel's stats.
Gating: `setDefaultMemberPermissions(ManageMessages)` + `requireStaffRole`,
ephemeral. No `member` → caller's own; `member` set → only if caller ∈
`STATS_ADMIN_IDS`, else "You can only view your own stats."
The command shows the **full** count metric set (not a trimmed subset); `source`
toggles between combined and per-channel views. Aggregation: MongoDB pipeline over
`StaffAction` (+ `Ticket` for volume) filtered by actor, `createdAt >= now - window`,
and optional `ticketType`, grouped by `type`/`tier`/`ticketType`/`priority`.
### Display (embed, v1 — counts)
```
📊 Stats — @member · last 30 days (email 30 · discord 12)
Claimed: 42 (while escalated — Tier 2: 5 · Tier 3: 1)
Closed: 38 (unclaimed: 4)
Resolved (credited): 35
Escalated: Tier 2: 4 · Tier 3: 1
De-escalated: Tier 2: 1 · Tier 3: 0
Transfers: in 2 · out 3 Reopens (their resolved tickets): 1
```
Timing/busiest-times not shown in v1 — collected for the website.
## Configuration
```
STATS_ADMIN_IDS=321754640431710226,691678135527276614,224692549225283584
```
(chicken, broccoli, ketchup) — comma-separated; users allowed to view others' stats.
## Non-goals (v1)
- Tag / Gmail-folder / canned-response usage tracking (dropped).
- Unclaim-action tracking (we track unclaimed-*at-close*).
- Multi-staff leaderboard (per-member view only).
- Website dashboard, graphs, timing/busiest-time displays, per-game/per-emailer
reports, internal-API stats endpoint. **Deferred** — data captured now.
- SLA / business-hours adjustment of timings (website computes from raw UTC).
- Event-log pruning (volume is low; revisit if needed).
## Verification
- Unit: period parser; aggregation shaping (counts by type/tier/ticketType/priority;
resolved-credit; unclaimed-at-close; transfer in/out; reopen).
- Unit: idempotency — no event on no-op claim/close.
- Manual: run claim/respond/escalate/deescalate/transfer/close/reopen across a test
email ticket and a test discord ticket; confirm `StaffAction` docs and fields,
`ticket.game`/`closedAt`; confirm per-ticket timeline yields timing deltas;
`/stats` counts + email/discord split; non-admin can't view others, admin can.
Deploy `docker compose up --build -d`; confirm bot logs ready.
```

View File

@@ -21,6 +21,7 @@ const {
sanitizeEmbedText sanitizeEmbedText
} = require('./utils'); } = require('./utils');
const { getGmailClient } = require('./services/gmail'); const { getGmailClient } = require('./services/gmail');
const { moveThreadToFolder } = require('./services/gmailLabels');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError } = require('./services/debugLog'); const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue'); const { enqueueSend } = require('./services/channelQueue');
@@ -37,7 +38,6 @@ function setPollSuspended(val) {
pollSuspended = !!val; pollSuspended = !!val;
if (!pollSuspended) authErrorNotified = false; if (!pollSuspended) authErrorNotified = false;
} }
function isPollSuspended() { return pollSuspended; }
// ============================================================ // ============================================================
// Helpers (extracted from the original 309-line poll()). // Helpers (extracted from the original 309-line poll()).
@@ -73,6 +73,16 @@ function locateGuild(client) {
* - followupBody: defensive — strip quotes but fall back to raw text if * - followupBody: defensive — strip quotes but fall back to raw text if
* stripping leaves nothing. Used for follow-up posts on an existing thread. * stripping leaves nothing. Used for follow-up posts on an existing thread.
*/ */
// Shared final cleanup for both the first-message and follow-up body paths:
// drop the "Get Outlook for ..." mobile-signature line, strip a dangling
// trailing "<" left by truncated HTML, and trim.
function finalizeBody(text) {
return text
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
}
function parseGmailMessage(email) { function parseGmailMessage(email) {
const headers = email.data.payload.headers; const headers = email.data.payload.headers;
const from = headers.find(h => h.name === 'From')?.value || ''; const from = headers.find(h => h.name === 'From')?.value || '';
@@ -92,10 +102,7 @@ function parseGmailMessage(email) {
firstBody = stripMobileFooter(firstBody); firstBody = stripMobileFooter(firstBody);
firstBody = firstBody.replace(/^\s*\n+/g, ''); firstBody = firstBody.replace(/^\s*\n+/g, '');
firstBody = firstBody.replace(/\n{3,}/g, '\n\n'); firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
firstBody = firstBody firstBody = finalizeBody(firstBody);
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const rawText = rawBody.replace(/\r\n/g, '\n'); const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText); let followupBody = stripEmailQuotes(rawText);
@@ -103,10 +110,7 @@ function parseGmailMessage(email) {
followupBody = followupBody.replace(/^\s*\n*/, '\n'); followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n'); followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody); followupBody = stripMobileFooter(followupBody);
followupBody = followupBody followupBody = finalizeBody(followupBody);
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
return { return {
isSelf, isSelf,
@@ -150,22 +154,11 @@ async function findOrCreateTicketChannel(guild, parsed, number) {
const channel = await guild.channels.create({ const channel = await guild.channels.create({
name: chanName, name: chanName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
parent: parentCategoryId, parent: parentCategoryId
// Email tickets have no Discord creator — the customer is reachable // Permissions are inherited from the ticket category — configure that
// only by email. So the only per-channel allow is the staff role; we // category to deny @everyone View Channel and allow the staff role, so
// still explicitly deny @everyone in case the category permissions // tickets stay staff-only. Inheriting (rather than setting per-channel
// are ever misconfigured to grant View Channel server-wide. // overwrites here) means the bot does not need the Manage Roles permission.
permissionOverwrites: [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
...(CONFIG.ROLE_ID_TO_PING ? [{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}] : [])
]
}); });
return { channel, parentCategoryId }; return { channel, parentCategoryId };
} catch (createErr) { } catch (createErr) {
@@ -274,7 +267,7 @@ async function poll(client) {
const gmail = getGmailClient(); const gmail = getGmailClient();
const list = await gmail.users.messages.list({ const list = await gmail.users.messages.list({
userId: 'me', userId: 'me',
q: 'is:unread category:primary' q: 'is:unread in:inbox'
}); });
if (!list.data.messages) return; if (!list.data.messages) return;
@@ -307,11 +300,15 @@ async function poll(client) {
if (ticketChan) { if (ticketChan) {
// Append follow-up to existing channel. // Append follow-up to existing channel.
const truncatedFollowup = parsed.followupBody.slice(0, 1800); const truncatedFollowup = parsed.followupBody.slice(0, 1800);
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions. // No staff role ping; body is attacker-controlled email content — suppress all mentions.
await enqueueSend(ticketChan, { await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`, content: `**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: ['roles'] } allowedMentions: { parse: [] }
}); });
// Follow-up on an existing thread: archive the new message only. Leave
// whatever managed folder staff filed this thread under untouched.
console.log('Archiving/reading Gmail message', msgRef.id);
await markGmailMessageRead(gmail, msgRef);
} else { } else {
// Create a new ticket channel. // Create a new ticket channel.
const limitCheck = await checkTicketLimits(parsed.senderEmail); const limitCheck = await checkTicketLimits(parsed.senderEmail);
@@ -342,10 +339,9 @@ async function poll(client) {
); );
const welcomeMsg = await enqueueSend(ticketChan, { const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed], embeds: [ticketInfoEmbed],
components: [buttons], components: [buttons],
allowedMentions: { parse: ['roles'] } allowedMentions: { parse: [] }
}); });
const { createStaffThread } = require('./services/staffThread'); const { createStaffThread } = require('./services/staffThread');
@@ -389,10 +385,13 @@ async function poll(client) {
}, },
{ upsert: true, new: true } { upsert: true, new: true }
)); ));
}
console.log('Archiving/reading Gmail message', msgRef.id); // New (or reopened) ticket: file the email thread into Triage — out of
await markGmailMessageRead(gmail, msgRef); // the inbox, marked read, awaiting staff action. The threads.modify also
// clears UNREAD, so a success archives it like markGmailMessageRead did.
console.log('Filing Gmail thread into Triage', parsed.threadId);
await moveThreadToFolder(parsed.threadId, 'TRIAGE', gmail);
}
} }
authErrorNotified = false; authErrorNotified = false;
} catch (e) { } catch (e) {
@@ -405,4 +404,4 @@ async function poll(client) {
} }
} }
module.exports = { poll, setPollSuspended, isPollSuspended }; module.exports = { poll, setPollSuspended };

View File

@@ -16,7 +16,6 @@ const {
AttachmentBuilder, AttachmentBuilder,
EmbedBuilder, EmbedBuilder,
MessageFlags, MessageFlags,
PermissionFlagsBits,
ModalBuilder, ModalBuilder,
TextInputBuilder, TextInputBuilder,
TextInputStyle TextInputStyle
@@ -25,10 +24,12 @@ const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets'); const { makeTicketName, resolveCreatorNickname, getOrCreateTicketCategory, cleanupEmptyOverflowCategory, checkTicketCreationRateLimit, toDiscordSafeName } = require('../services/tickets');
const { sendTicketClosedEmail } = require('../services/gmail'); const { sendTicketClosedEmail } = require('../services/gmail');
const { getTicketActionRow } = require('../utils/ticketComponents'); const { moveThreadToFolder } = require('../services/gmailLabels');
const { getTicketActionRow, ticketChannelOverwrites } = require('../utils/ticketComponents');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../services/transcript');
const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils'); const { sanitizeEmbedText, truncateEmbedDescription } = require('../utils');
const { enqueueRename, enqueueSend } = require('../services/channelQueue'); const { enqueueRename, enqueueSend } = require('../services/channelQueue');
const { runEscalation, runDeescalation } = require('./commands'); const { runEscalation, runDeescalation, resolveEscalationCategoryId } = require('./commands');
const { pendingCloses } = require('./pendingCloses'); const { pendingCloses } = require('./pendingCloses');
const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread'); const { addMemberToStaffThread, createStaffThread } = require('../services/staffThread');
const { pinMessage } = require('../services/pinMessage'); const { pinMessage } = require('../services/pinMessage');
@@ -309,7 +310,7 @@ async function handleConfirmCloseRequest(interaction, ticket) {
await runFinalClose(interaction, freshTicket, effectiveSendEmail); await runFinalClose(interaction, freshTicket, effectiveSendEmail);
}, timerSeconds * 1000)); }, timerSeconds * 1000));
pendingCloses.set(channelId, { timeout: timerId, userId: interaction.user.id, username: userTag, sendEmail }); pendingCloses.set(channelId, { timeout: timerId, username: userTag, sendEmail });
} }
async function handleCancelCloseRequest(interaction) { async function handleCancelCloseRequest(interaction) {
@@ -358,10 +359,7 @@ async function handleEscalateButton(interaction, ticket) {
return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral }); return interaction.reply({ content: `This ticket is already at tier ${tier + 1}.`, flags: MessageFlags.Ephemeral });
} }
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const categoryId = resolveEscalationCategoryId(ticket, tier);
const categoryId = tier === 1
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
if (!categoryId && !interaction.channel.isThread()) { if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ return interaction.reply({
@@ -370,7 +368,7 @@ async function handleEscalateButton(interaction, ticket) {
}); });
} }
await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier, null)); await runDeferred(interaction, 'escalate', () => runEscalation(interaction, ticket, tier));
} }
async function handleDeescalateButton(interaction, ticket) { async function handleDeescalateButton(interaction, ticket) {
@@ -425,7 +423,8 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
if (transcriptChan) { if (transcriptChan) {
transcriptMsg = await enqueueSend(transcriptChan, { transcriptMsg = await enqueueSend(transcriptChan, {
content: transcriptContent, content: transcriptContent,
files: [file] files: [file],
allowedMentions: { parse: [] }
}); });
} }
@@ -450,6 +449,12 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
{ $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } } { $set: { discordThreadId: null, status: 'closed' }, $unset: { welcomeMessageId: '' } }
); );
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!ticket.gmailThreadId?.startsWith('discord-')) {
moveThreadToFolder(ticket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
if (transcriptMsg?.id) { if (transcriptMsg?.id) {
await Transcript.create({ await Transcript.create({
gmailThreadId: ticket.gmailThreadId, gmailThreadId: ticket.gmailThreadId,
@@ -474,33 +479,6 @@ async function runFinalClose(interaction, ticket, sendEmail = true) {
} }
} }
/** Render the last 100 messages of a channel as a plaintext transcript. */
async function buildTranscriptText(channel, ticket) {
const messages = await channel.messages.fetch({ limit: 100 });
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
}
function formatDateForTranscript(d) {
return new Date(d).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
}
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelName)
.replace(/\{email\}/g, senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
}
async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) { async function dmTranscriptToCreator(client, ticket, channelName, transcriptText, openedStr, closedStr) {
// Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for // Prefer ticket.creatorId (stored on creation). Fall back to legacy parsing for
// pre-creatorId modal tickets only — split-pop returns the wrong value for // pre-creatorId modal tickets only — split-pop returns the wrong value for
@@ -549,7 +527,7 @@ async function postCloseLogEntry(interaction, ticket, channelName) {
} else { } else {
logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`; logMsg = `Closed **${channelName}** (${ticket.senderEmail}) by ${closerMention} (${closerDisplayName})`;
} }
await enqueueSend(logChan, logMsg); await enqueueSend(logChan, { content: logMsg, allowedMentions: { parse: [] } });
} }
// ============================================================ // ============================================================
@@ -601,17 +579,7 @@ async function handleTicketModal(interaction) {
name: unclaimedName, name: unclaimedName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
parent: parentCategoryIdForTicket, parent: parentCategoryIdForTicket,
permissionOverwrites: [ permissionOverwrites: ticketChannelOverwrites(guild, interaction.user.id)
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: interaction.user.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
}); });
} catch (err) { } catch (err) {
console.error('guild.channels.create (ticket modal):', err); console.error('guild.channels.create (ticket modal):', err);
@@ -647,7 +615,7 @@ async function handleTicketModal(interaction) {
if (CONFIG.LOGGING_CHANNEL_ID) { if (CONFIG.LOGGING_CHANNEL_ID) {
const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null); const logChan = await interaction.client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
if (logChan) { if (logChan) {
await enqueueSend(logChan, `📝 ${channel.name} created by ${interaction.user.tag}`); await enqueueSend(logChan, { content: `📝 ${channel.name} created by ${interaction.user.tag}`, allowedMentions: { parse: [] } });
} }
} }
} catch (err) { } catch (err) {
@@ -732,6 +700,20 @@ const TICKET_BUTTON_HANDLERS = {
deescalate_ticket: handleDeescalateButton deescalate_ticket: handleDeescalateButton
}; };
/**
* TICKET_BUTTON_HANDLERS entries that any user with channel access may
* invoke — not just staff. Ticket creators and /add'd users get to close
* their own ticket (with the 60s countdown still in place) and cancel a
* pending close. Claim/escalate/de-escalate stay staff-only.
*/
const PUBLIC_TICKET_BUTTONS = new Set([
'close_ticket',
'confirm_close',
'confirm_close_with_email',
'confirm_close_no_email',
'cancel_close'
]);
async function handleButton(interaction) { async function handleButton(interaction) {
const { customId } = interaction; const { customId } = interaction;
@@ -757,13 +739,12 @@ async function handleButton(interaction) {
const ticketHandler = TICKET_BUTTON_HANDLERS[customId]; const ticketHandler = TICKET_BUTTON_HANDLERS[customId];
if (!ticketHandler) return; if (!ticketHandler) return;
// Every TICKET_BUTTON_HANDLERS entry mutates ticket state // Claim / escalate / de-escalate mutate staff-owned ticket state and stay
// (claim/close/confirm_close*/cancel_close/escalate*/deescalate). The slash // staff-only. Close-related buttons (close_ticket, confirm_close*,
// command dispatcher in handlers/commands/index.js gates these via // cancel_close) are public so a ticket creator can close their own ticket;
// requireStaffRole; the button dispatcher must do the same — non-staff // the 60s force-close countdown still applies, and the cancel button is
// members with view access to the ticket channel (creator, /add'd users) // intentionally visible to anyone in the channel so any party can abort.
// could otherwise click Claim, Escalate, Close, etc. if (!PUBLIC_TICKET_BUTTONS.has(customId) && (await requireStaffRole(interaction))) return;
if (await requireStaffRole(interaction)) return;
const ticket = await findTicketForChannel( const ticket = await findTicketForChannel(
interaction, interaction,

View File

@@ -11,9 +11,11 @@ const { AttachmentBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection'); const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { enqueueSend } = require('../../services/channelQueue'); const { enqueueSend } = require('../../services/channelQueue');
const { logTicketEvent } = require('../../services/debugLog'); const { logTicketEvent, logError } = require('../../services/debugLog');
const { moveThreadToFolder } = require('../../services/gmailLabels');
const { pendingCloses } = require('../pendingCloses'); const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader } = require('../../services/transcript');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
@@ -56,7 +58,7 @@ async function handleForceClose(interaction) {
const channelRef = interaction.channel; const channelRef = interaction.channel;
const clientRef = interaction.client; const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000); const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag }); pendingCloses.set(channelRef.id, { timeout: timerId, username: interaction.user.tag });
} }
/** Performs the actual force-close work after the countdown elapses. */ /** Performs the actual force-close work after the countdown elapses. */
@@ -73,6 +75,12 @@ async function finalizeForceClose(channelRef, clientRef) {
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } } { $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
); );
// File the email thread into the Resolved folder — non-fatal, email tickets only.
if (!freshTicket.gmailThreadId.startsWith('discord-')) {
moveThreadToFolder(freshTicket.gmailThreadId, 'RESOLVED')
.catch(err => logError('gmailLabels: resolved move', err).catch(() => {}));
}
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...'); await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr => await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
console.error('Transcript error (force-close):', tErr) console.error('Transcript error (force-close):', tErr)
@@ -92,14 +100,7 @@ async function finalizeForceClose(channelRef, clientRef) {
async function postTranscript(channelRef, clientRef, freshTicket) { async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE); await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 }); const log = await buildTranscriptText(channelRef, freshTicket);
const log =
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), { const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelRef.name}.txt` name: `transcript-${channelRef.name}.txt`
}); });
@@ -109,20 +110,10 @@ async function postTranscript(channelRef, clientRef, freshTicket) {
.catch(() => null); .catch(() => null);
if (!transcriptChan) return; if (!transcriptChan) return;
const fmt = (d) => new Date(d).toLocaleString('en-US', { const openedStr = formatDateForTranscript(freshTicket.createdAt);
month: '2-digit', day: '2-digit', year: 'numeric', const closedStr = formatDateForTranscript(new Date());
hour: '2-digit', minute: '2-digit', second: '2-digit', const transcriptContent = renderTranscriptHeader(channelRef.name, freshTicket.senderEmail, openedStr, closedStr);
hour12: true, timeZoneName: 'short' await enqueueSend(transcriptChan, { content: transcriptContent, files: [file], allowedMentions: { parse: [] } });
});
const openedStr = fmt(freshTicket.createdAt);
const closedStr = fmt(new Date());
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelRef.name)
.replace(/\{email\}/g, freshTicket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
} }
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose }; module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };

View File

@@ -6,14 +6,13 @@
const { const {
ChannelType, ChannelType,
EmbedBuilder, EmbedBuilder,
MessageFlags, MessageFlags
PermissionFlagsBits
} = require('discord.js'); } = require('discord.js');
const { mongoose } = require('../../db-connection'); const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { getPriorityEmoji } = require('../../utils'); const { getPriorityEmoji } = require('../../utils');
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets'); const { checkTicketCreationRateLimit, getOrCreateTicketCategory, makeTicketName } = require('../../services/tickets');
const { getTicketActionRow } = require('../../utils/ticketComponents'); const { getTicketActionRow, ticketChannelOverwrites } = require('../../utils/ticketComponents');
const { enqueueSend } = require('../../services/channelQueue'); const { enqueueSend } = require('../../services/channelQueue');
const { logError } = require('../../services/debugLog'); const { logError } = require('../../services/debugLog');
@@ -36,6 +35,8 @@ async function handleCreateTicketFromMessage(interaction) {
const guild = interaction.guild; const guild = interaction.guild;
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean(); const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1; const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
const creatorNickname = message.member?.displayName || message.author.username;
const unclaimedName = makeTicketName('unclaimed', { ticketNumber }, creatorNickname);
let parentCategoryIdForTicket; let parentCategoryIdForTicket;
try { try {
@@ -52,20 +53,10 @@ async function handleCreateTicketFromMessage(interaction) {
let channel; let channel;
try { try {
channel = await guild.channels.create({ channel = await guild.channels.create({
name: `ticket-${ticketNumber}`, name: unclaimedName,
type: ChannelType.GuildText, type: ChannelType.GuildText,
parent: parentCategoryIdForTicket, parent: parentCategoryIdForTicket,
permissionOverwrites: [ permissionOverwrites: ticketChannelOverwrites(guild, message.author.id)
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{
id: message.author.id,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
}
]
}); });
} catch (err) { } catch (err) {
console.error('guild.channels.create (context menu ticket):', err); console.error('guild.channels.create (context menu ticket):', err);

View File

@@ -10,6 +10,7 @@ const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets'); const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { sendTicketNotificationEmail } = require('../../services/gmail'); const { sendTicketNotificationEmail } = require('../../services/gmail');
const { moveThreadToFolder } = require('../../services/gmailLabels');
const { getTicketActionRow } = require('../../utils/ticketComponents'); const { getTicketActionRow } = require('../../utils/ticketComponents');
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue'); const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
const { pinMessage } = require('../../services/pinMessage'); const { pinMessage } = require('../../services/pinMessage');
@@ -19,15 +20,26 @@ const { fetchLoggingChannel } = require('./helpers');
const Ticket = mongoose.model('Ticket'); const Ticket = mongoose.model('Ticket');
/**
* Resolve the destination category for an escalation target tier
* (nextTier 1 = tier 2, 2 = tier 3), picking the Discord vs email category set
* by ticket origin. Returns null/undefined when the relevant category is unset.
*/
function resolveEscalationCategoryId(ticket, nextTier) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
if (nextTier === 1) {
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
}
return isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID;
}
/** /**
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must * Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred. * validate ticket and currentTier < nextTier, and have already deferred.
*/ */
async function runEscalation(interaction, ticket, nextTier, reason) { async function runEscalation(interaction, ticket, nextTier) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1 const categoryId = resolveEscalationCategoryId(ticket, nextTier);
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
// Clear claim on escalation // Clear claim on escalation
await Ticket.updateOne( await Ticket.updateOne(
@@ -87,11 +99,20 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
try { try {
const escalatorName = interaction.member?.displayName || interaction.user.username; const escalatorName = interaction.member?.displayName || interaction.user.username;
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.${reason ? `\n\nReason: ${reason}` : ''}`; // Editable via TICKET_ESCALATION_EMAIL_MESSAGE in .env. Placeholders:
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id); // {escalator_name}, {tier}; \n for line breaks.
const emailBody = (CONFIG.TICKET_ESCALATION_EMAIL_MESSAGE || '')
.replace(/\\n/g, '\n')
.replace(/\{escalator_name\}/g, escalatorName)
.replace(/\{tier\}/g, tierLabel);
await sendTicketNotificationEmail(ticket, emailBody, interaction.user.id);
} catch (emailErr) { } catch (emailErr) {
console.error('Escalation email failed (non-fatal):', emailErr.message); console.error('Escalation email failed (non-fatal):', emailErr.message);
} }
// File the email thread into the Escalated folder — non-fatal, never blocks
// the escalation.
moveThreadToFolder(ticket.gmailThreadId, 'ESCALATED')
.catch(err => logError('gmailLabels: escalate move', err).catch(() => {}));
} }
if (nextTier === 2 && ticket.welcomeMessageId) { if (nextTier === 2 && ticket.welcomeMessageId) {
@@ -107,9 +128,10 @@ async function runEscalation(interaction, ticket, nextTier, reason) {
if (logChan) { if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email'; const ticketType = isDiscordTicket ? 'Discord' : 'Email';
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3'; const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
await enqueueSend(logChan, await enqueueSend(logChan, {
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.\nReason: ${reason}` content: `${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`,
); allowedMentions: { parse: [] }
});
} }
} }
@@ -157,14 +179,14 @@ async function runDeescalation(interaction, ticket) {
const logChan = await fetchLoggingChannel(interaction.client); const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) { if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email'; const ticketType = isDiscordTicket ? 'Discord' : 'Email';
await enqueueSend(logChan, await enqueueSend(logChan, {
`${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.` content: `${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.`,
); allowedMentions: { parse: [] }
});
} }
} }
async function handleEscalate(interaction) { async function handleEscalate(interaction) {
const reason = null;
const level = interaction.options.getString('level'); const level = interaction.options.getString('level');
const nextTier = level === '3' ? 2 : 1; const nextTier = level === '3' ? 2 : 1;
@@ -180,9 +202,7 @@ async function handleEscalate(interaction) {
} }
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-'); const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1 const categoryId = resolveEscalationCategoryId(ticket, nextTier);
? (isDiscordTicket ? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID : CONFIG.EMAIL_ESCALATED2_CHANNEL_ID)
: (isDiscordTicket ? CONFIG.DISCORD_ESCALATED3_CHANNEL_ID : CONFIG.EMAIL_ESCALATED3_CHANNEL_ID);
const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3'; const configKey = nextTier === 1 ? 'ESCALATED2' : 'ESCALATED3';
if (!categoryId && !interaction.channel.isThread()) { if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({ return interaction.reply({
@@ -192,7 +212,7 @@ async function handleEscalate(interaction) {
} }
await runDeferred(interaction, 'escalate', () => await runDeferred(interaction, 'escalate', () =>
runEscalation(interaction, ticket, nextTier, reason) runEscalation(interaction, ticket, nextTier)
); );
} }
@@ -211,4 +231,4 @@ async function handleDeescalate(interaction) {
); );
} }
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate }; module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId };

View File

@@ -17,13 +17,17 @@
const { EmbedBuilder, MessageFlags } = require('discord.js'); const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection'); const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config'); const { CONFIG } = require('../../config');
const { isStaff } = require('../../utils');
const { setNotifyDm } = require('../../services/staffSettings'); const { setNotifyDm } = require('../../services/staffSettings');
const { enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue'); const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { logTicketEvent } = require('../../services/debugLog'); const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
const { logError, logTicketEvent } = require('../../services/debugLog');
const { applyConfigUpdates } = require('../../services/configPersistence');
const { moveThreadToFolder, folderDisplayName } = require('../../services/gmailLabels');
const { findTicketForChannel } = require('../sharedHelpers'); const { findTicketForChannel } = require('../sharedHelpers');
const { requireStaffRole, fetchLoggingChannel } = require('./helpers'); const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation'); const { runEscalation, runDeescalation, handleEscalate, handleDeescalate, resolveEscalationCategoryId } = require('./escalation');
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close'); const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete } = require('./response'); const { handleResponse, handleAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel'); const { handlePanel, handleSignature } = require('./panel');
@@ -54,16 +58,20 @@ async function handleAdd(interaction) {
const ticket = await findTicketForChannel(interaction); const ticket = await findTicketForChannel(interaction);
if (!ticket) return; if (!ticket) return;
// Defer up front: enqueueOverwrite serializes behind any pending rename/move
// on this channel and can exceed Discord's 3s interaction-token window.
await interaction.deferReply();
try { try {
await enqueueOverwrite(interaction.channel, user.id, { await enqueueOverwrite(interaction.channel, user.id, {
ViewChannel: true, ViewChannel: true,
SendMessages: true, SendMessages: true,
ReadMessageHistory: true ReadMessageHistory: true
}); });
await interaction.reply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } }); await interaction.editReply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) { } catch (err) {
console.error('Add user error:', err); console.error('Add user error:', err);
await interaction.reply({ content: 'Failed to add user.', flags: MessageFlags.Ephemeral }); await interaction.editReply({ content: 'Failed to add user.' }).catch(() => {});
} }
} }
@@ -72,12 +80,15 @@ async function handleRemove(interaction) {
const ticket = await findTicketForChannel(interaction); const ticket = await findTicketForChannel(interaction);
if (!ticket) return; if (!ticket) return;
// Defer up front — same reason as handleAdd.
await interaction.deferReply();
try { try {
await enqueueOverwrite(interaction.channel, user.id, null, 'delete'); await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
await interaction.reply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } }); await interaction.editReply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) { } catch (err) {
console.error('Remove user error:', err); console.error('Remove user error:', err);
await interaction.reply({ content: 'Failed to remove user.', flags: MessageFlags.Ephemeral }); await interaction.editReply({ content: 'Failed to remove user.' }).catch(() => {});
} }
} }
@@ -87,23 +98,54 @@ async function handleTransfer(interaction) {
const ticket = await findTicketForChannel(interaction); const ticket = await findTicketForChannel(interaction);
if (!ticket) return; if (!ticket) return;
const staffRoleId = CONFIG.ROLE_TO_PING_ID; // Cache-first member resolution; falls back to a fetch if not in cache.
const guildMember = await interaction.guild.members.fetch(member.id).catch(() => null); // GuildMembers intent keeps the cache warm in normal operation.
const guildMember = interaction.guild.members.cache.get(member.id)
|| await interaction.guild.members.fetch(member.id).catch(() => null);
if (!guildMember || !guildMember.roles.cache.has(staffRoleId)) { // Reject self-transfers and bots; require the target to satisfy isStaff(),
return interaction.reply({ content: 'The target member must have the staff role.', flags: MessageFlags.Ephemeral }); // which covers ROLE_ID_TO_PING + ADDITIONAL_STAFF_ROLES — the same staff
// definition used by every other gate in the bot. The previous check only
// looked at ROLE_TO_PING_ID, missing additional staff roles.
if (!guildMember || guildMember.user.bot || !isStaff(guildMember)) {
return interaction.reply({
content: 'The target member must have the staff role.',
flags: MessageFlags.Ephemeral
});
} }
if (guildMember.id === interaction.user.id) {
return interaction.reply({
content: 'You cannot transfer the ticket to yourself.',
flags: MessageFlags.Ephemeral
});
}
// Defer before the DB write + rename so the interaction token survives.
await interaction.deferReply();
try { try {
const claimerLabel = guildMember.displayName || guildMember.user.username; const claimerLabel = guildMember.displayName || guildMember.user.username;
await Ticket.updateOne( await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $set: { claimedBy: claimerLabel } } { $set: { claimedBy: claimerLabel, claimerId: guildMember.id } }
); );
ticket.claimedBy = claimerLabel;
ticket.claimerId = guildMember.id;
// Rename the channel to reflect the new claimer — mirrors the /claim
// button flow (applyClaim in handlers/buttons.js). Picks the new
// claimer's emoji from STAFF_EMOJIS and uses the escalated-claimed
// variant when tier >= 1.
const claimerEmoji = CONFIG.STAFF_EMOJIS[guildMember.id] || CONFIG.CLAIMER_EMOJI_FALLBACK;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const state = tier >= 1 ? 'escalated-claimed' : 'claimed';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname, claimerEmoji))
.catch(err => logError('rename', err).catch(() => {}));
// `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping. // `reason` is staff-supplied freeform text; gate to user pings so @everyone in it can't mass-ping.
await interaction.reply({ await interaction.editReply({
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`, content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] } allowedMentions: { parse: ['users'] }
}); });
@@ -112,12 +154,12 @@ async function handleTransfer(interaction) {
if (logChan) { if (logChan) {
await enqueueSend(logChan, { await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`, content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] } allowedMentions: { parse: [] }
}); });
} }
} catch (err) { } catch (err) {
console.error('Transfer error:', err); console.error('Transfer error:', err);
await interaction.reply({ content: 'Failed to transfer ticket.', flags: MessageFlags.Ephemeral }); await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
} }
} }
@@ -126,19 +168,24 @@ async function handleMove(interaction) {
const ticket = await findTicketForChannel(interaction); const ticket = await findTicketForChannel(interaction);
if (!ticket) return; if (!ticket) return;
// Defer up front — enqueueMove serializes behind any pending rename and
// setParent itself can take a moment on busy channels.
await interaction.deferReply();
try { try {
await enqueueMove(interaction.channel, category.id); await enqueueMove(interaction.channel, category.id);
await interaction.reply(`Moved ticket to **${category.name}**.`); await interaction.editReply(`Moved ticket to **${category.name}**.`);
const logChan = await fetchLoggingChannel(interaction.client); const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) { if (logChan) {
await enqueueSend(logChan, await enqueueSend(logChan, {
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}` content: `Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`,
); allowedMentions: { parse: [] }
});
} }
} catch (err) { } catch (err) {
console.error('Move error:', err); console.error('Move error:', err);
await interaction.reply({ content: 'Failed to move ticket.', flags: MessageFlags.Ephemeral }); await interaction.editReply({ content: 'Failed to move ticket.' }).catch(() => {});
} }
} }
@@ -147,12 +194,15 @@ async function handleTopic(interaction) {
const ticket = await findTicketForChannel(interaction); const ticket = await findTicketForChannel(interaction);
if (!ticket) return; if (!ticket) return;
// Defer up front — enqueueTopic serializes behind any pending rename/move.
await interaction.deferReply();
try { try {
await enqueueTopic(interaction.channel, text); await enqueueTopic(interaction.channel, text);
await interaction.reply('Topic updated successfully.'); await interaction.editReply('Topic updated successfully.');
} catch (err) { } catch (err) {
console.error('Topic error:', err); console.error('Topic error:', err);
await interaction.reply({ content: 'Failed to update topic.', flags: MessageFlags.Ephemeral }); await interaction.editReply({ content: 'Failed to update topic.' }).catch(() => {});
} }
} }
@@ -198,6 +248,16 @@ async function handleGmailPoll(interaction) {
// drop below 30s and trip Gmail's per-user quota under sustained load. // drop below 30s and trip Gmail's per-user quota under sustained load.
const ms = Math.max(30000, requested * 1000); const ms = Math.max(30000, requested * 1000);
const seconds = ms / 1000; const seconds = ms / 1000;
// While the inbound email flow is off, setting an interval must NOT silently
// restart polling. Record it for this session (matches /gmailpoll's existing
// runtime-only model) so it applies the next time someone runs /email on.
if (!CONFIG.GMAIL_POLL_ENABLED) {
CONFIG.GMAIL_POLL_INTERVAL_MS = ms;
return interaction.reply({
content: `Interval saved (${seconds}s), but the inbound email flow is currently **off** — it will apply when you run \`/email on\`.`,
flags: MessageFlags.Ephemeral
});
}
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle. // Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
const { setGmailPollInterval } = require('../../broccolini-discord'); const { setGmailPollInterval } = require('../../broccolini-discord');
setGmailPollInterval(ms); setGmailPollInterval(ms);
@@ -208,6 +268,82 @@ async function handleGmailPoll(interaction) {
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral }); return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
} }
async function handleEmail(interaction) {
const sub = interaction.options.getSubcommand();
if (sub === 'status') {
const intervalSec = Math.round(CONFIG.GMAIL_POLL_INTERVAL_MS / 1000);
return interaction.reply({
content: `Inbound email flow is **${CONFIG.GMAIL_POLL_ENABLED ? 'on' : 'off'}**.\nPoll interval: ${intervalSec}s.`,
flags: MessageFlags.Ephemeral
});
}
const enable = sub === 'on';
// applyConfigUpdates writes both CONFIG and .env so the state survives restart.
const { applied, errors } = applyConfigUpdates({ GMAIL_POLL_ENABLED: enable });
if (!applied.includes('GMAIL_POLL_ENABLED')) {
const reason = (errors.find(e => e.key === 'GMAIL_POLL_ENABLED') || {}).error || 'unknown error';
return interaction.reply({
content: `Failed to turn email flow ${enable ? 'on' : 'off'}: ${reason}`,
flags: MessageFlags.Ephemeral
});
}
// Lazy require — broccolini-discord re-exports these and we'd otherwise cycle.
const { setGmailPollInterval, clearGmailPollInterval } = require('../../broccolini-discord');
if (enable) {
// Clear any auth-suspend latch so a prior invalid_grant doesn't keep polling
// dead. If auth is still broken, the next cycle re-suspends and DMs admin.
try { require('../../gmail-poll').setPollSuspended(false); } catch (_) {}
setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
} else {
clearGmailPollInterval();
}
logTicketEvent('Email flow toggled', [
{ name: 'State', value: enable ? 'on' : 'off' },
{ name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.reply({
content: enable
? 'Inbound email flow is now **on** — the inbox will be polled.'
: 'Inbound email flow is now **off** — the inbox will not be polled. Outbound emails still send.',
flags: MessageFlags.Ephemeral
});
}
async function handleFolder(interaction) {
const folderKey = interaction.options.getString('destination');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
// Discord-origin tickets have no Gmail thread to file.
if (ticket.gmailThreadId.startsWith('discord-')) {
return interaction.reply({
content: "This ticket has no email thread, so it can't be moved to a Gmail folder.",
flags: MessageFlags.Ephemeral
});
}
const label = folderDisplayName(folderKey) || 'Spam';
// Defer: resolving/creating labels + threads.modify can exceed the 3s window.
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
await moveThreadToFolder(ticket.gmailThreadId, folderKey);
logTicketEvent('Email thread filed', [
{ name: 'Folder', value: label },
{ name: 'Filed by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.editReply({ content: `Moved this ticket's email thread to **${label}**.` });
} catch (err) {
logError('handleFolder', err, interaction).catch(() => {});
return interaction.editReply({ content: `Failed to move the email thread: ${err.message}` });
}
}
async function handleHelp(interaction) { async function handleHelp(interaction) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle('Ticket System - Commands') .setTitle('Ticket System - Commands')
@@ -219,7 +355,7 @@ async function handleHelp(interaction) {
}, },
{ {
name: 'Ticket Management', name: 'Ticket Management',
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description' value: '`/transfer @staff [reason]` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/cancel-close` - Abort a pending force-close countdown\n`/topic <text>` - Set ticket topic/description\n`/folder <destination>` - File this ticket\'s email into a Gmail folder'
}, },
{ {
name: 'Saved Responses', name: 'Saved Responses',
@@ -235,10 +371,18 @@ async function handleHelp(interaction) {
}, },
{ {
name: 'Escalation', name: 'Escalation',
value: '`/escalate [reason] [tier]` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)' value: '`/escalate <level>` - Escalate ticket (tier 2 or 3, or one step)\n`/deescalate` - De-escalate ticket (tier 3→2 or tier 2→normal)'
},
{
name: 'Staff Configuration',
value: '`/notifydm` - Toggle DM notifications for your claimed tickets\n`/signature` - Set your email signature\n`/closetimer <seconds>` - Set the force-close countdown\n`/staffthread` - Toggle/configure per-ticket staff threads\n`/pinmessages` - Toggle auto-pinning of ticket messages\n`/gmailpoll <interval>` - Set the Gmail poll interval\n`/email on|off|status` - Turn the inbound email flow on/off'
},
{
name: 'Right-click (Apps menu)',
value: '`Create Ticket From Message` - Turn a message into a ticket\n`View User Tickets` - Show a user\'s recent tickets'
} }
]) ])
.setFooter({ text: 'Click buttons on ticket messages to claim/close' }); .setFooter({ text: 'Click buttons on ticket messages to claim/close. Config changes via slash commands apply until the next restart.' });
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
} }
@@ -258,6 +402,8 @@ const COMMAND_HANDLERS = {
staffthread: handleStaffThread, staffthread: handleStaffThread,
pinmessages: handlePinMessages, pinmessages: handlePinMessages,
gmailpoll: handleGmailPoll, gmailpoll: handleGmailPoll,
email: handleEmail,
folder: handleFolder,
closetimer: handleCloseTimer, closetimer: handleCloseTimer,
'cancel-close': handleCancelClose, 'cancel-close': handleCancelClose,
'force-close': handleForceClose, 'force-close': handleForceClose,
@@ -295,5 +441,6 @@ module.exports = {
handleContextMenu, handleContextMenu,
handleAutocomplete, handleAutocomplete,
runEscalation, runEscalation,
runDeescalation runDeescalation,
resolveEscalationCategoryId
}; };

View File

@@ -3,9 +3,8 @@
*/ */
const { mongoose } = require('../db-connection'); const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { extractRawEmail, isStaff } = require('../utils'); const { extractRawEmail, isStaff, getCleanBody } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail'); const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings'); const { getNotifyDm } = require('../services/staffSettings');
const { logError } = require('../services/debugLog'); const { logError } = require('../services/debugLog');
@@ -78,6 +77,10 @@ async function handleDiscordReply(m) {
'Support'; 'Support';
const msgId = const msgId =
last.payload.headers.find(h => h.name === 'Message-ID')?.value; last.payload.headers.find(h => h.name === 'Message-ID')?.value;
const origDate =
last.payload.headers.find(h => h.name === 'Date')?.value || '';
const origFrom =
last.payload.headers.find(h => h.name === 'From')?.value || recipient;
const recipientEmail = extractRawEmail(recipient).toLowerCase(); const recipientEmail = extractRawEmail(recipient).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) { if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) {
@@ -85,16 +88,18 @@ async function handleDiscordReply(m) {
return; return;
} }
// Quote the customer's latest inbound message beneath the staff reply.
const quote = { from: origFrom, date: origDate, body: getCleanBody(last.payload) };
await sendGmailReply( await sendGmailReply(
ticket.gmailThreadId, ticket.gmailThreadId,
m.content, m.content,
recipientEmail, recipientEmail,
subject, subject,
msgId, msgId,
m.author.id m.author.id,
quote
); );
await updateTicketActivity(ticket.gmailThreadId);
} catch (e) { } catch (e) {
console.error('REPLY ERROR:', e); console.error('REPLY ERROR:', e);
} }

View File

@@ -1,7 +1,7 @@
/** /**
* Shared pending-close timer map. * Shared pending-close timer map.
* Keyed by channel.id → { timeout, userId, username }. * Keyed by channel.id → { timeout, username, sendEmail }.
* Used by buttons.js (sets timers) and commands.js (cancel-close clears them). * Used by buttons.js (sets timers) and commands/ (cancel-close clears them).
*/ */
const pendingCloses = new Map(); const pendingCloses = new Map();

View File

@@ -15,7 +15,6 @@ const ticketSchema = new mongoose.Schema({
escalationTier: { type: Number, default: 0 }, escalationTier: { type: Number, default: 0 },
ticketNumber: Number, ticketNumber: Number,
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] }, priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
ticketTag: String,
lastActivity: Date, lastActivity: Date,
welcomeMessageId: String, welcomeMessageId: String,
claimerId: String, claimerId: String,

View File

@@ -193,8 +193,4 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
} }
}); });
// Expose the allowlist for the Phase 8 schema smoke test. Attached to the
// router function object; doesn't show up as a route.
router._allowedKeys = Array.from(ALLOWED_CONFIG_KEYS);
module.exports = router; module.exports = router;

View File

@@ -24,29 +24,28 @@ const ALLOWED_CONFIG_KEYS = new Set([
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
// Roles and staff // Roles and staff
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES', 'ROLE_ID_TO_PING', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'ADMIN_ID', 'ADMIN_ID',
// Channel IDs // Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'RENAME_LOG_CHANNEL_ID',
// Messages and labels // Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE', 'TICKET_WELCOME_MESSAGE', 'TICKET_CLAIMED_MESSAGE', 'TICKET_UNCLAIMED_MESSAGE',
'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding // Branding
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST', 'LOGO_URL', 'SUPPORT_NAME', 'GAME_LIST',
// Toggles // Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS', 'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'ALLOW_CLAIM_OVERWRITE', 'ALLOW_CLAIM_OVERWRITE', 'TRANSCRIPT_DM_TO_CREATOR',
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', 'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE', 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
// Limits and thresholds // Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'GLOBAL_TICKET_LIMIT',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'GMAIL_POLL_ENABLED',
// Embed colors // Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI' 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
@@ -140,26 +139,6 @@ const VALIDATORS = {
return { ok: true, coerced: parts.join(',') }; return { ok: true, coerced: parts.join(',') };
} }
}, },
json: {
type: 'json',
validate(value) {
if (isEmptyInput(value)) return { ok: true, coerced: '' };
const str = String(value);
try {
JSON.parse(str);
return { ok: true, coerced: str };
} catch (_) {
return { ok: false, error: 'must be valid JSON' };
}
}
},
string_or_json: {
type: 'string_or_json',
validate(value) {
if (value === null || value === undefined) return { ok: false, error: 'cannot be null' };
return { ok: true, coerced: String(value) };
}
},
// Fallback. Preserves legacy coercion so CONFIG.* values keep their types // Fallback. Preserves legacy coercion so CONFIG.* values keep their types
// for consumers that compare with === true / === 5 (see old applyConfigUpdates). // for consumers that compare with === true / === 5 (see old applyConfigUpdates).
string: { string: {
@@ -184,6 +163,8 @@ function inferType(key) {
if (key.includes('COLOR')) return 'hex_color'; if (key.includes('COLOR')) return 'hex_color';
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it. // ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
if (key === 'ROLE_ID_TO_PING') return 'discord_id'; if (key === 'ROLE_ID_TO_PING') return 'discord_id';
// Boolean toggle whose name doesn't match the ENABLED/_ON pattern.
if (key === 'TRANSCRIPT_DM_TO_CREATOR') return 'boolean';
// 2. Name patterns // 2. Name patterns
if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean'; if (/ENABLED$|^USE_|_ON$/.test(key)) return 'boolean';

View File

@@ -36,7 +36,7 @@ async function sendToChannel(channelId, embed, overrideClient) {
if (!c || !channelId) return; if (!c || !channelId) return;
try { try {
const channel = await c.channels.fetch(channelId); const channel = await c.channels.fetch(channelId);
if (channel) await channel.send({ embeds: [embed] }); if (channel) await channel.send({ embeds: [embed], allowedMentions: { parse: [] } });
} catch (_) { } catch (_) {
// ignore send failures // ignore send failures
} }
@@ -59,7 +59,8 @@ async function logError(context, error, interaction = null, overrideClient = nul
const message = redactPII(error.message || String(error)); const message = redactPII(error.message || String(error));
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500); const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
await channel.send({ await channel.send({
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\`` content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``,
allowedMentions: { parse: [] }
}); });
} catch (_) { } catch (_) {
// ignore send failures // ignore send failures

View File

@@ -23,7 +23,6 @@ function buildCompanySigHtml() {
Indifferent Broccoli Support<br> Indifferent Broccoli Support<br>
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br> <a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</a><br>
Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br> Join us on <a href="https://discord.gg/2vmfrrtvJY">Discord</a><br>
<br>
<em>Host your own game server. Or not... we don't care.</em> <em>Host your own game server. Or not... we don't care.</em>
</td> </td>
</tr> </tr>
@@ -35,7 +34,6 @@ function buildCompanySigText() {
'Indifferent Broccoli Support', 'Indifferent Broccoli Support',
'https://indifferentbroccoli.com/', 'https://indifferentbroccoli.com/',
'Join us on Discord: https://discord.gg/2vmfrrtvJY', 'Join us on Discord: https://discord.gg/2vmfrrtvJY',
'',
"Host your own game server. Or not... we don't care." "Host your own game server. Or not... we don't care."
].join('\n'); ].join('\n');
} }
@@ -96,23 +94,169 @@ function encodeReplySubject(baseSubject) {
} }
// Compose and send a multipart/alternative reply on an existing Gmail thread. // Compose and send a multipart/alternative reply on an existing Gmail thread.
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) { // Build the "On <date>, <sender> wrote:" attribution line for a quoted reply.
function formatQuoteAttribution(quote) {
const who = (quote.from || '').trim() || 'the sender';
const when = (quote.date || '').trim();
return when ? `On ${when}, ${who} wrote:` : `${who} wrote:`;
}
// Plain-text quoted block: attribution + each original line prefixed with "> ".
// Returns null when there is nothing to quote.
function buildQuoteText(quote) {
if (!quote || !(quote.body || '').trim()) return null;
const quoted = quote.body.replace(/\r\n/g, '\n').split('\n').map(l => `> ${l}`).join('\n');
return `${formatQuoteAttribution(quote)}\n${quoted}`;
}
// HTML quoted block. Mirrors Gmail's own reply markup (gmail_quote / gmail_attr
// classes + the standard blockquote styling) so receiving clients recognize it
// as quoted content and collapse it behind the "•••" toggle. Body is
// attacker-controlled email content — escapeHtml it.
function buildQuoteHtml(quote) {
if (!quote || !(quote.body || '').trim()) return '';
const attribution = escapeHtml(formatQuoteAttribution(quote));
const quotedHtml = escapeHtml(quote.body.replace(/\r\n/g, '\n')).replace(/\n/g, '<br>');
return `<div class="gmail_quote">` +
`<div dir="ltr" class="gmail_attr">${attribution}<br></div>` +
`<blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex;">${quotedHtml}</blockquote>` +
`</div>`;
}
// Discord custom emoji token: <:name:id> (static) or <a:name:id> (animated).
const DISCORD_EMOJI_RE = /<(a?):(\w+):(\d+)>/g;
// Same token after escapeHtml has turned the angle brackets into entities.
const DISCORD_EMOJI_RE_ESCAPED = /&lt;(a?):(\w+):(\d+)&gt;/g;
// Plain-text: collapse a custom-emoji token to its :name: shortcode.
function discordEmojiToText(s) {
return (s || '').replace(DISCORD_EMOJI_RE, (_m, _anim, name) => `:${name}:`);
}
// Collect the distinct custom emoji referenced in a message.
function collectDiscordEmojis(s) {
const seen = new Map();
for (const m of (s || '').matchAll(DISCORD_EMOJI_RE)) {
const [, anim, name, id] = m;
if (!seen.has(id)) seen.set(id, { id, name, ext: anim ? 'gif' : 'png' });
}
return [...seen.values()];
}
// Fetch one emoji's bytes from Discord's CDN for inline (cid:) embedding.
// Returns null on any failure so the caller can fall back to a remote <img>.
async function fetchEmojiInline(emoji) {
try {
const res = await fetch(`https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.ext}`);
if (!res.ok) return null;
const base64 = Buffer.from(await res.arrayBuffer()).toString('base64');
return { ...emoji, base64, cid: `emoji-${emoji.id}@broccolini` };
} catch {
return null;
}
}
// HTML: escape first (body is staff-authored but treated as untrusted), then
// swap the now-escaped emoji tokens for an inline <img>. Prefer a cid: reference
// (embedded part, always renders); fall back to Discord's CDN when not embedded.
// The id is digits-only and name is \w+, so neither can break out of the attribute.
function messageTextToHtml(s, cidById = {}) {
return escapeHtml(s || '')
.replace(DISCORD_EMOJI_RE_ESCAPED, (_m, anim, name, id) => {
const ext = anim ? 'gif' : 'png';
const src = cidById[id] ? `cid:${cidById[id]}` : `https://cdn.discordapp.com/emojis/${id}.${ext}`;
return `<img src="${src}" alt=":${name}:" ` +
`width="20" height="20" style="vertical-align: middle;">`;
})
.replace(/\n/g, '<br>');
}
// Strip Discord role mentions (<@&id>) — internal staff pings like @broccolini
// that mean nothing to an email recipient. Collapse the whitespace left behind.
function stripRoleMentions(s) {
return (s || '')
.replace(/<@&\d+>/g, '')
.replace(/[^\S\r\n]{2,}/g, ' ')
.replace(/[^\S\r\n]+\n/g, '\n')
.trim();
}
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId, quote = null }) {
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' }; const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : ''; const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = sigBlocks.text; const safeStaffSigText = sigBlocks.text;
const cleanText = stripRoleMentions(messageText);
// Embed any custom emoji inline (cid:) so they render without the recipient
// having to load remote images. Failed fetches fall back to a remote <img>.
const inlineEmojis = (await Promise.all(collectDiscordEmojis(cleanText).map(fetchEmojiInline))).filter(Boolean);
const cidById = {};
for (const e of inlineEmojis) cidById[e.id] = e.cid;
const quoteHtml = buildQuoteHtml(quote);
const htmlBody = ` const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;"> <div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p> <p>${messageTextToHtml(cleanText, cidById)}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''} ${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()} ${buildCompanySigHtml()}
${quoteHtml ? `<br><br>${quoteHtml}` : ''}
</div>`; </div>`;
const plainBody = [messageText || '']; const plainBody = [discordEmojiToText(cleanText)];
if (safeStaffSigText) plainBody.push('', safeStaffSigText); if (safeStaffSigText) plainBody.push('', safeStaffSigText);
plainBody.push('', ...buildCompanySigText().split('\n')); plainBody.push('', ...buildCompanySigText().split('\n'));
const quoteText = buildQuoteText(quote);
if (quoteText) plainBody.push('', '', quoteText);
const stamp = Date.now().toString(16);
const altBoundary = 'alt_' + stamp;
const altPart = [
`--${altBoundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
...plainBody,
'',
`--${altBoundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody,
'',
`--${altBoundary}--`
];
// With no inline images the message stays a plain multipart/alternative.
// With them, wrap the alternative + image parts in a multipart/related.
let topContentType;
let bodyLines;
if (inlineEmojis.length) {
const relBoundary = 'rel_' + stamp;
topContentType = `multipart/related; boundary="${relBoundary}"`;
bodyLines = [
`--${relBoundary}`,
`Content-Type: multipart/alternative; boundary="${altBoundary}"`,
'',
...altPart,
''
];
for (const e of inlineEmojis) {
bodyLines.push(
`--${relBoundary}`,
`Content-Type: image/${e.ext === 'gif' ? 'gif' : 'png'}`,
'Content-Transfer-Encoding: base64',
`Content-ID: <${e.cid}>`,
`Content-Disposition: inline; filename="${e.name}.${e.ext}"`,
'',
...(e.base64.match(/.{1,76}/g) || []),
''
);
}
bodyLines.push(`--${relBoundary}--`);
} else {
topContentType = `multipart/alternative; boundary="${altBoundary}"`;
bodyLines = altPart;
}
const boundary = '000000000000' + Date.now().toString(16);
const headers = [ const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipient}`, `To: ${recipient}`,
@@ -120,24 +264,10 @@ async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, m
msgId && `In-Reply-To: ${msgId}`, msgId && `In-Reply-To: ${msgId}`,
msgId && `References: ${msgId}`, msgId && `References: ${msgId}`,
'MIME-Version: 1.0', 'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"` `Content-Type: ${topContentType}`
].filter(Boolean); ].filter(Boolean);
const raw = Buffer.from([ const raw = Buffer.from([...headers, '', ...bodyLines].join('\r\n'))
...headers,
'',
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'',
...plainBody,
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody,
'',
`--${boundary}--`
].join('\r\n'))
.toString('base64') .toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
@@ -164,15 +294,20 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) {
const gmail = getGmailClient(); const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support'); const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`; // Editable via TICKET_CLOSE_MESSAGE in .env. Supports a {closer_name}
// placeholder and \n for line breaks.
const messageText = (CONFIG.TICKET_CLOSE_MESSAGE || '')
.replace(/\\n/g, '\n')
.replace(/\{closer_name\}/g, closerName);
// Closing emails intentionally omit the staff signature (userId left out)
// — only the resolution message and the company signature go out.
await sendThreadedEmail(gmail, { await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId, threadId: ticket.gmailThreadId,
recipient, recipient,
encodedSubject, encodedSubject,
msgId, msgId,
messageText, messageText
userId
}); });
} catch (err) { } catch (err) {
console.error('Ticket closed email error:', err); console.error('Ticket closed email error:', err);
@@ -182,18 +317,17 @@ async function sendTicketClosedEmail(ticket, closerName, userId = null) {
/** /**
* Send a notification email in the ticket thread (e.g. escalation, high-priority). * Send a notification email in the ticket thread (e.g. escalation, high-priority).
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject * @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
* @param {string} subjectLine - Fallback subject if the thread can't be queried
* @param {string} messageBody - Plain or HTML message body * @param {string} messageBody - Plain or HTML message body
* @param {string} [userId] - Discord user ID for signature (optional) * @param {string} [userId] - Discord user ID for signature (optional)
*/ */
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) { async function sendTicketNotificationEmail(ticket, messageBody, userId = null) {
try { try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail'); const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
if (!recipient) return; if (!recipient) return;
const gmail = getGmailClient(); const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId); const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support'); const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
await sendThreadedEmail(gmail, { await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId, threadId: ticket.gmailThreadId,
@@ -212,7 +346,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, use
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId * Send a Gmail reply on an existing thread. Caller supplies subject + messageId
* (typically pulled from the latest non-self message in the thread). * (typically pulled from the latest non-self message in the thread).
*/ */
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) { async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null, quote = null) {
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase(); const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
if (!EMAIL_RE.test(safeRecipient)) { if (!EMAIL_RE.test(safeRecipient)) {
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {}); logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
@@ -226,7 +360,8 @@ async function sendGmailReply(threadId, replyText, recipientEmail, subject, mess
encodedSubject: encodeReplySubject(subject || 'Support'), encodedSubject: encodeReplySubject(subject || 'Support'),
msgId: sanitizeHeaderValue(messageId) || null, msgId: sanitizeHeaderValue(messageId) || null,
messageText: replyText, messageText: replyText,
userId userId,
quote
}); });
} }

152
services/gmailLabels.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* Gmail "folder" routing — map a ticket's Gmail thread into a managed set of
* labels with exclusive-folder semantics.
*
* Gmail labels are additive; we synthesize folders by, on every move, adding the
* target label and removing every *other* managed label plus INBOX + UNREAD
* (removing an absent label is a no-op, so this is idempotent). "Spam" maps to
* the built-in system SPAM label, which is never created.
*
* Acyclic require graph: this module depends on services/gmail (getGmailClient);
* gmail.js does not depend back on this file.
*/
'use strict';
const { CONFIG } = require('../config');
const { getGmailClient } = require('./gmail');
// Logical folder key -> how to resolve its label. User folders read their display
// name from CONFIG (env-configurable); SPAM is the Gmail system label.
const FOLDER_DEFS = {
TRIAGE: { configKey: 'GMAIL_LABEL_TRIAGE' },
ESCALATED: { configKey: 'GMAIL_LABEL_ESCALATED' },
RESOLVED: { configKey: 'GMAIL_LABEL_RESOLVED' },
FOR_JAKE: { configKey: 'GMAIL_LABEL_FOR_JAKE' },
DASHBOARD_ERRORS: { configKey: 'GMAIL_LABEL_DASHBOARD_ERRORS' },
PARTNERSHIP_OFFERS: { configKey: 'GMAIL_LABEL_PARTNERSHIP_OFFERS' },
SPAM: { system: 'SPAM' }
};
// User-managed folder keys (everything but the system SPAM label).
const MANAGED_USER_KEYS = Object.keys(FOLDER_DEFS).filter(k => !FOLDER_DEFS[k].system);
// Always stripped on a move so the thread leaves the inbox and is marked read.
const ALWAYS_REMOVE = ['INBOX', 'UNREAD'];
// Cache: Gmail label display name -> label ID. Populated lazily; cleared on a
// stale-label error so a label recreated in Gmail is re-resolved.
const labelIdByName = new Map();
/** Display name for a user folder key (null for the system SPAM label). */
function folderDisplayName(key) {
const def = FOLDER_DEFS[key];
if (!def) throw new Error(`Unknown folder key: ${key}`);
if (def.system) return null;
return CONFIG[def.configKey];
}
async function ensureLabelCache(gmail) {
if (labelIdByName.size > 0) return;
const res = await gmail.users.labels.list({ userId: 'me' });
for (const label of res.data.labels || []) {
labelIdByName.set(label.name, label.id);
}
}
/**
* Resolve a folder key to a Gmail label ID, creating a missing *user* label.
* SPAM short-circuits to the system id and is never created.
*/
async function resolveLabelId(gmail, key) {
const def = FOLDER_DEFS[key];
if (!def) throw new Error(`Unknown folder key: ${key}`);
if (def.system) return def.system;
const name = folderDisplayName(key);
await ensureLabelCache(gmail);
if (labelIdByName.has(name)) return labelIdByName.get(name);
const created = await gmail.users.labels.create({
userId: 'me',
requestBody: { name, labelListVisibility: 'labelShow', messageListVisibility: 'show' }
});
labelIdByName.set(name, created.data.id);
return created.data.id;
}
/**
* Pure: given the target key and a key->id map of every managed label, build the
* add/remove sets for an exclusive-folder move. The target label is added; every
* other managed label plus INBOX + UNREAD is removed.
*/
function computeLabelMutation(targetKey, idByKey) {
const targetId = idByKey[targetKey];
if (!targetId) throw new Error(`Missing resolved id for target folder: ${targetKey}`);
const removeLabelIds = [];
for (const key of Object.keys(idByKey)) {
if (key === targetKey) continue;
const id = idByKey[key];
if (id) removeLabelIds.push(id);
}
for (const sys of ALWAYS_REMOVE) removeLabelIds.push(sys);
return { addLabelIds: [targetId], removeLabelIds };
}
function isInvalidLabelError(err) {
const status = err && ((err.response && err.response.status) || err.code);
const msg = (err && err.message) || '';
return status === 400 || /invalid label|labelId not found/i.test(msg);
}
/**
* Move a Gmail thread into a managed folder with exclusive-folder semantics.
* Resolves (and creates) every managed label, then issues one threads.modify.
* On a stale cached label id (400 invalid label), clears the cache and retries
* once.
*
* @param {string} threadId Gmail thread id (ticket.gmailThreadId)
* @param {string} targetKey one of FOLDER_DEFS keys
* @param {object} [gmail] optional Gmail client (poll loop passes its own)
*/
async function moveThreadToFolder(threadId, targetKey, gmail = getGmailClient()) {
if (!threadId) throw new Error('moveThreadToFolder: threadId required');
if (!FOLDER_DEFS[targetKey]) throw new Error(`Unknown folder key: ${targetKey}`);
const applyOnce = async () => {
const idByKey = {};
for (const key of Object.keys(FOLDER_DEFS)) {
idByKey[key] = await resolveLabelId(gmail, key);
}
const mutation = computeLabelMutation(targetKey, idByKey);
await gmail.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: mutation
});
};
try {
await applyOnce();
} catch (err) {
if (isInvalidLabelError(err)) {
labelIdByName.clear();
await applyOnce();
} else {
throw err;
}
}
}
module.exports = {
FOLDER_DEFS,
MANAGED_USER_KEYS,
ALWAYS_REMOVE,
folderDisplayName,
resolveLabelId,
computeLabelMutation,
moveThreadToFolder,
// test seam: clear the name->id cache between cases
__clearLabelCache: () => labelIdByName.clear()
};

View File

@@ -1,6 +1,6 @@
/** /**
* Ticket database helpers counters, rename, limits, auto-close, * Ticket database helpers counters, rename, limits, auto-close,
* reminders, auto-unclaim, channel creation. * auto-unclaim, channel creation.
*/ */
const { ChannelType } = require('discord.js'); const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection'); const { mongoose, withRetry } = require('../db-connection');
@@ -269,15 +269,6 @@ async function checkTicketLimits(senderEmail) {
return { ok: true }; return { ok: true };
} }
// --- ACTIVITY ---
async function updateTicketActivity(gmailThreadId) {
await Ticket.updateOne(
{ gmailThreadId },
{ $set: { lastActivity: new Date() } }
);
}
// --- SCHEDULED CHECKS --- // --- SCHEDULED CHECKS ---
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps. // These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
@@ -291,11 +282,11 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
lastActivity: { $lt: cutoffTime, $ne: null } lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean()); }).sort({ createdAt: 1 }).limit(500).lean());
const guild = client.guilds.cache.first();
if (!guild) return;
for (const ticket of staleTickets) { for (const ticket of staleTickets) {
try { try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE); await enqueueSend(channel, CONFIG.DISCORD_AUTO_CLOSE_MESSAGE);
@@ -337,11 +328,11 @@ async function checkAutoUnclaim(client) {
lastActivity: { $lt: unclaimTime, $ne: null } lastActivity: { $lt: unclaimTime, $ne: null }
}).lean()); }).lean());
const guild = client.guilds.cache.first();
if (!guild) return;
for (const ticket of staleClaimedTickets) { for (const ticket of staleClaimedTickets) {
try { try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
await withRetry(() => Ticket.updateOne( await withRetry(() => Ticket.updateOne(
@@ -426,7 +417,6 @@ module.exports = {
makeTicketName, makeTicketName,
checkTicketCreationRateLimit, checkTicketCreationRateLimit,
checkTicketLimits, checkTicketLimits,
updateTicketActivity,
checkAutoClose, checkAutoClose,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels, reconcileDeletedTicketChannels,

37
services/transcript.js Normal file
View File

@@ -0,0 +1,37 @@
/**
* Shared transcript rendering for the two close paths
* (handlers/buttons.js runFinalClose and handlers/commands/close.js postTranscript).
* Pure formatting only — each caller owns its own posting / DB / email side effects.
*/
const { CONFIG } = require('../config');
/** Render the last 100 messages of a channel as a plaintext transcript. */
async function buildTranscriptText(channel, ticket) {
const messages = await channel.messages.fetch({ limit: 100 });
return `TRANSCRIPT: ${ticket.subject}\nUser: ${ticket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
}
/** Format a date for the transcript header (US locale, 12h, with time zone). */
function formatDateForTranscript(d) {
return new Date(d).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
}
/** Build the transcript-channel message body from DISCORD_TRANSCRIPT_MESSAGE. */
function renderTranscriptHeader(channelName, senderEmail, openedStr, closedStr) {
return CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelName)
.replace(/\{email\}/g, senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
}
module.exports = { buildTranscriptText, formatDateForTranscript, renderTranscriptHeader };

View File

@@ -84,7 +84,6 @@
<div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div> <div class="field"><label>Debugging Channel</label><input type="text" data-key="DEBUGGING_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div> <div class="field"><label>Gmail Log Channel</label><input type="text" data-key="GMAIL_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div> <div class="field"><label>Automation Log Channel</label><input type="text" data-key="AUTOMATION_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Rename Log Channel</label><input type="text" data-key="RENAME_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div> <div class="field"><label>Security Log Channel</label><input type="text" data-key="SECURITY_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div> <div class="field"><label>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
</div></div> </div></div>
@@ -123,8 +122,6 @@
<div class="section-body"><div class="field-grid"> <div class="section-body"><div class="field-grid">
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div> <div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div> <div class="field"><label>Auto-Close Hours</label><input type="number" data-key="AUTO_CLOSE_AFTER_HOURS"></div>
<div class="field"><label>Reminders</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="REMINDER_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Reminder Hours</label><input type="number" data-key="REMINDER_AFTER_HOURS"></div>
<div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div> <div class="field"><label>Auto-Unclaim</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_UNCLAIM_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div> <div class="field"><label>Auto-Unclaim Hours</label><input type="number" data-key="AUTO_UNCLAIM_AFTER_HOURS"></div>
<div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div> <div class="field"><label>Allow Claim Overwrite</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="ALLOW_CLAIM_OVERWRITE"><span class="slider"></span></label><span>Enabled</span></div></div>
@@ -136,7 +133,6 @@
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div> <div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div> <div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div> <div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
<div class="field full-width"><label>Reminder Message</label><textarea data-key="REMINDER_MESSAGE" rows="2"></textarea><div class="hint">Variables: {ping}, {hours}</div></div>
</div></div> </div></div>
</div> </div>

152
tests/gmailLabels.test.js Normal file
View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
computeLabelMutation,
resolveLabelId,
moveThreadToFolder,
folderDisplayName,
__clearLabelCache
} from '../services/gmailLabels.js';
const FULL_IDS = {
TRIAGE: 'L_TRIAGE',
ESCALATED: 'L_ESC',
RESOLVED: 'L_RES',
FOR_JAKE: 'L_FJ',
DASHBOARD_ERRORS: 'L_DE',
PARTNERSHIP_OFFERS: 'L_PO',
SPAM: 'SPAM'
};
const FULL_LABELS = [
{ name: 'Triage', id: 'L_TRIAGE' },
{ name: 'Escalated', id: 'L_ESC' },
{ name: 'Resolved', id: 'L_RES' },
{ name: 'For Jake', id: 'L_FJ' },
{ name: 'Dashboard Errors', id: 'L_DE' },
{ name: 'Partnership Offers', id: 'L_PO' }
];
describe('computeLabelMutation', () => {
it('adds the target, removes every other managed label plus INBOX/UNREAD', () => {
const { addLabelIds, removeLabelIds } = computeLabelMutation('FOR_JAKE', FULL_IDS);
expect(addLabelIds).toEqual(['L_FJ']);
expect(removeLabelIds).toContain('INBOX');
expect(removeLabelIds).toContain('UNREAD');
expect(removeLabelIds).toContain('SPAM');
expect(removeLabelIds).toContain('L_TRIAGE');
expect(removeLabelIds).not.toContain('L_FJ'); // target is never removed
});
it('moving to SPAM adds SPAM and removes all user labels but not SPAM itself', () => {
const { addLabelIds, removeLabelIds } = computeLabelMutation('SPAM', FULL_IDS);
expect(addLabelIds).toEqual(['SPAM']);
expect(removeLabelIds).not.toContain('SPAM');
expect(removeLabelIds).toContain('L_TRIAGE');
expect(removeLabelIds).toContain('INBOX');
});
it('throws when the target id is missing', () => {
expect(() => computeLabelMutation('FOR_JAKE', { TRIAGE: 'x' })).toThrow();
});
});
describe('folderDisplayName', () => {
it('returns null for the system SPAM folder', () => {
expect(folderDisplayName('SPAM')).toBeNull();
});
it('returns the configured/default name for a user folder', () => {
expect(folderDisplayName('FOR_JAKE')).toBe('For Jake');
});
it('throws on an unknown key', () => {
expect(() => folderDisplayName('NOPE')).toThrow();
});
});
describe('resolveLabelId', () => {
beforeEach(() => __clearLabelCache());
it('short-circuits SPAM to the system id without any API call', async () => {
let called = false;
const gmail = { users: { labels: { list: async () => { called = true; return { data: {} }; } } } };
expect(await resolveLabelId(gmail, 'SPAM')).toBe('SPAM');
expect(called).toBe(false);
});
it('returns an existing label id matched by name', async () => {
const gmail = {
users: { labels: {
list: async () => ({ data: { labels: [{ name: 'For Jake', id: 'L_EXISTING' }] } }),
create: async () => { throw new Error('should not create'); }
} }
};
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_EXISTING');
});
it('creates a missing user label under its configured name and caches it', async () => {
let createdName = null;
const gmail = {
users: { labels: {
list: async () => ({ data: { labels: [] } }),
create: async ({ requestBody }) => { createdName = requestBody.name; return { data: { id: 'L_NEW' } }; }
} }
};
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_NEW');
expect(createdName).toBe('For Jake');
});
});
describe('moveThreadToFolder', () => {
beforeEach(() => __clearLabelCache());
it('resolves labels then issues one threads.modify with exclusive sets', async () => {
let modifyArgs = null;
const gmail = {
users: {
labels: {
list: async () => ({ data: { labels: FULL_LABELS } }),
create: async () => { throw new Error('no create expected'); }
},
threads: { modify: async (args) => { modifyArgs = args; return { data: {} }; } }
}
};
await moveThreadToFolder('thread123', 'ESCALATED', gmail);
expect(modifyArgs.id).toBe('thread123');
expect(modifyArgs.requestBody.addLabelIds).toEqual(['L_ESC']);
expect(modifyArgs.requestBody.removeLabelIds).toContain('L_TRIAGE');
expect(modifyArgs.requestBody.removeLabelIds).toContain('INBOX');
expect(modifyArgs.requestBody.removeLabelIds).not.toContain('L_ESC');
});
it('clears the cache and retries once on an invalid-label error', async () => {
let modifyCalls = 0;
let listCalls = 0;
const gmail = {
users: {
labels: {
list: async () => { listCalls++; return { data: { labels: FULL_LABELS } }; },
create: async () => ({ data: { id: 'X' } })
},
threads: {
modify: async () => {
modifyCalls++;
if (modifyCalls === 1) { const e = new Error('invalid label'); e.code = 400; throw e; }
return { data: {} };
}
}
}
};
await moveThreadToFolder('t1', 'TRIAGE', gmail);
expect(modifyCalls).toBe(2);
expect(listCalls).toBe(2); // cache was cleared and labels re-listed
});
it('rejects an unknown folder key before touching the API', async () => {
const gmail = {
users: {
labels: { list: async () => ({ data: { labels: [] } }) },
threads: { modify: async () => ({}) }
}
};
await expect(moveThreadToFolder('t', 'BOGUS', gmail)).rejects.toThrow();
});
});

View File

@@ -22,9 +22,6 @@ function isStaff(member) {
// --- TEXT PROCESSING --- // --- TEXT PROCESSING ---
const BLOCK_TAG_REGEX =
/<\/(p|div|li|h[1-6]|tr|table|section|article|blockquote)>/gi;
function escapeRegex(str) { function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
@@ -40,28 +37,6 @@ function escapeHtml(str) {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function decodeHtmlEntities(str) {
if (!str) return '';
return str
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ');
}
function htmlToTextWithBlocks(html) {
return decodeHtmlEntities(
html
.replace(/\r\n/g, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(BLOCK_TAG_REGEX, '\n\n')
.replace(/<(ul|ol)[^>]*>/gi, '\n')
.replace(/<[^>]*>?/gm, '')
);
}
// --- EMAIL BODY EXTRACTION --- // --- EMAIL BODY EXTRACTION ---
function decodeGmailData(p) { function decodeGmailData(p) {
@@ -277,7 +252,6 @@ module.exports = {
escapeHtml, escapeHtml,
safeEqual, safeEqual,
isStaff, isStaff,
htmlToTextWithBlocks,
getCleanBody, getCleanBody,
stripEmailQuotes, stripEmailQuotes,
stripMobileFooter, stripMobileFooter,

View File

@@ -2,16 +2,36 @@
* Ticket action row builder Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2). * Ticket action row builder Close, Claim, Escalate (if tier < 3), Deescalate (if tier >= 2).
* Used by handlers/buttons.js and handlers/commands.js. * Used by handlers/buttons.js and handlers/commands.js.
*/ */
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js'); const { ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionFlagsBits } = require('discord.js');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
/**
* permissionOverwrites for a Discord-originated ticket channel: deny @everyone,
* allow the creating user and the staff ping role. Used by the button and
* context-menu creation paths (the email/gmail path differs — no Discord
* creator — and builds its own overwrites).
* @param {import('discord.js').Guild} guild
* @param {string} creatorId - Discord user ID of the ticket creator
*/
function ticketChannelOverwrites(guild, creatorId) {
const allow = [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
];
return [
{ id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
{ id: creatorId, allow },
{ id: CONFIG.ROLE_ID_TO_PING, allow }
];
}
/** /**
* Build the standard ticket action row (Close, Claim, optionally Escalate, optionally Deescalate). * Build the standard ticket action row (Close, Claim, optionally Escalate, optionally Deescalate).
* @param {Object} ticket - Ticket with escalationTier (0, 1, 2) and optionally escalated * @param {Object} ticket - Ticket with escalationTier (0, 1, 2) and optionally escalated
* @param {Object} [options] - { unclaimLabel, unclaimEmoji } for claim button when ticket is claimed
* @returns {ActionRowBuilder} * @returns {ActionRowBuilder}
*/ */
function getTicketActionRow(ticket, options = {}) { function getTicketActionRow(ticket) {
const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0); const tier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const row = new ActionRowBuilder(); const row = new ActionRowBuilder();
@@ -23,8 +43,8 @@ function getTicketActionRow(ticket, options = {}) {
.setStyle(ButtonStyle.Secondary), .setStyle(ButtonStyle.Secondary),
new ButtonBuilder() new ButtonBuilder()
.setCustomId('claim_ticket') .setCustomId('claim_ticket')
.setLabel(options.unclaimLabel ?? CONFIG.BUTTON_LABEL_CLAIM) .setLabel(CONFIG.BUTTON_LABEL_CLAIM)
.setEmoji(options.unclaimEmoji ?? CONFIG.BUTTON_EMOJI_CLAIM) .setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
.setStyle(ButtonStyle.Secondary) .setStyle(ButtonStyle.Secondary)
); );
@@ -48,4 +68,4 @@ function getTicketActionRow(ticket, options = {}) {
return row; return row;
} }
module.exports = { getTicketActionRow }; module.exports = { getTicketActionRow, ticketChannelOverwrites };