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
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
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.
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.
Closes the remaining non-broccolini interaction paths after the prior
TICKET_BUTTON_HANDLERS gate. After this commit, every bot interaction is
staff-only except the panel buttons (open_ticket / open_ticket_thread /
open_ticket_channel) and their ticket-creation modal submit — those have
to stay public because they're how members and customers open tickets.
Specific changes:
- handlers/commands/index.js: handleCommand no longer has the
`!== 'help'` carve-out. /help now goes through requireStaffRole like
every other slash command. Non-staff get the same ephemeral
"only available to the support team" reply.
- broccolini-discord.js: the signature_modal_* modal-submit handler now
calls requireStaffRole before writing to StaffSignature. /signature
already gates the modal display via the slash-command staff check;
this is defense in depth against directly crafted submissions.
- handlers/buttons.js: cancel_delete_tag moved out of
FREE_BUTTON_HANDLERS and gated alongside confirm_delete_tag::*. The
dialog is only shown ephemerally to the staff who triggered
/response delete, so non-staff can't reach it in normal flow; gating
keeps the button surface consistent.
Kept public (by design — these are the customer entry points):
open_ticket / open_ticket_thread / open_ticket_channel buttons
ticket_modal / ticket_modal_thread / ticket_modal_channel submits
The 1028-line handlers/commands.js bundled escalation logic + force-close
flow + /response tag CRUD + /panel + /signature + context-menu handlers +
several config-toggle slash commands. After the dispatch-table refactor it
was still a god module. Split into handlers/commands/ with one file per
topic; require('./commands') resolves to handlers/commands/index.js
(handlers/commands.js is removed).
Layout:
helpers.js — requireStaffRole, fetchLoggingChannel
(cross-submodule, kept here to avoid cycles with index.js)
escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
(run* are still exported via index.js for handlers/buttons.js)
close.js — handleForceClose, handleCancelClose, handleCloseTimer
+ finalizeForceClose / postTranscript (timer callback)
response.js — handleResponse + send/create/edit/delete/list subcommands
+ handleAutocomplete (only /response autocompletes)
panel.js — handlePanel, buildPanelButtonRow, handleSignature
contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
index.js — dispatch tables, handleCommand/handleContextMenu, plus the
short-and-not-thematic handlers (notifydm, add, remove,
transfer, move, topic, staffthread, pinmessages, gmailpoll,
help) and the public re-exports.
No behavior change — every imported name, every Discord call, every DB
write, every embed, every reply payload preserved verbatim. Public surface
of require('./commands') is still { handleCommand, handleContextMenu,
handleAutocomplete, runEscalation, runDeescalation }.
Largest single module is now index.js at 299 lines; others are 33–214.