runFinalClose ran the transcript archive, creator DM, close log, and closure
email in the same try as the close transition and channel delete, with the
transcript posted *before* the commit. A failure in any of them (notably a
DiscordAPIError 50001 Missing Access when posting the transcript to the archive
channel) aborted the whole close: the customer saw 'ticket closed' but the DB
stayed open and the channel was never deleted.
Rewrite so the close transition + pendingDelete commit FIRST, each side-effect is
individually best-effort via a closeStep wrapper, and scheduleTicketChannelDelete
always runs. finalizeForceClose was already commit-first; wrap its remaining
unguarded archiving send too.
The button and slash close paths deleted the channel via a bare setTimeout that
never set the pendingDelete flag, so a restart in the 5s grace window orphaned
the channel (closed in DB, still present in Discord) with no recovery — only the
auto-close path used the flag correctly.
Extract scheduleTicketChannelDelete() in services/tickets.js: a grace-delayed,
queue-routed (enqueueDelete) delete that clears pendingDelete on success. All
three close paths now use it. Button/slash set pendingDelete:true and keep
discordThreadId populated so resumePendingDeletes() recovers the delete on the
next boot. The button path previously nulled discordThreadId before the delete,
which made the channel unrecoverable.
- Reply-cycle auto-advance: staff reply files the thread to "Awaiting Reply",
a customer response files it to "Needs Response" (new GMAIL_LABEL_AWAITING_REPLY
/ GMAIL_LABEL_NEEDS_RESPONSE labels + autoAdvanceFolder, which only moves
threads still in the auto-cycle and leaves hand-filed folders alone)
- /forward: forward a ticket's email to another address (handlers/commands/forward.js
+ forward composition in services/gmail.js)
- Tests for the auto-advance cycle; label fixtures updated for the new labels
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.
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.
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
QUAL-004 handlers/messages.js — DM-on-customer-reply now reads
guild.members.cache.get(claimerId) first and only falls back to
guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff
reply on busy tickets. GuildMembers intent already keeps the cache warm.
QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js
(finalizeForceClose) — close paths now $unset welcomeMessageId alongside
the status: 'closed' write. Stops a stale message-ID from carrying into a
future reopen on the same Gmail thread, where escalation's "edit welcome
buttons" path would silently fail trying to fetch a message in a deleted
channel.
QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now
includes the missing/extra key sets, not just count vs count. Saves the
operator from guessing which key vanished after a partial write.
QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match
loop with an earliest-match-across-all-markers scan. The previous code
could truncate at a late "_____" signature underline even when an earlier
"On X wrote:" reply header was the real cutoff. New test in
tests/utils.test.js exercises the dual-marker case.
QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer /
appReady` declarations from after the ready handler to before it. Same
runtime behavior (module-load completes before ready fires asynchronously),
but the read order now matches the assignment order.
SEC-002 routes/internalApi.js — POST /restart now goes through a tighter
2/min limiter on top of the shared 10/min internalLimiter. Defense in
depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret
can no longer crash-loop the container.
Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already
idempotent — the audit finding was incorrect).
vitest run: 88/88 (one new test for QUAL-008).
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.