Compare commits

...

34 Commits

Author SHA1 Message Date
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
2152544d09 escalation/de-escalation: keep ticket creator and /add'd users on the channel
enqueueMove called channel.setParent(categoryId, { lockPermissions: true }).
discord.js's default for setParent is also true. With lockPermissions: true,
Discord re-syncs the channel's permission overwrites to match the new
parent category — so the explicit per-user allows set at ticket creation
(creator) and via /add (extra members) got wiped on every escalate,
de-escalate, and /move. The creator literally lost View Channel on their
own ticket the moment staff hit Escalate.

Flip to lockPermissions: false so the existing channel-level overwrites
are preserved across the parent change. Inheritance still applies for
anything the channel doesn't override — and the deny-@everyone /
role-allow set at creation continues to gate access correctly.

Affects every caller of enqueueMove:
  - handlers/commands/escalation.js runEscalation
  - handlers/commands/escalation.js runDeescalation
  - handlers/commands/index.js handleMove (/move)
2026-05-19 20:09:58 +00:00
c79463fc2a security: gate /help, signature modal submit, and cancel_delete_tag on staff role
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
2026-05-19 19:58:41 +00:00
e8e114e4ad security: gate ticket buttons + tag-delete confirm on staff role
handleButton routed claim_ticket, close_ticket, confirm_close /
confirm_close_with_email / confirm_close_no_email, cancel_close,
escalate_ticket, escalate_to_tier2, escalate_to_tier3, deescalate_ticket,
and confirm_delete_tag::* straight to their handlers without any staff
check. Any non-staff member with View Channel on a ticket — the ticket
creator themselves, or anyone /add'd to it — could click those buttons
and mutate ticket state (claim, escalate, close, delete saved-response
tags).

The slash-command dispatcher in handlers/commands/index.js already
calls requireStaffRole before invoking any handler; the button
dispatcher needed the same gate. Now:

  - confirm_delete_tag::<name>  → requireStaffRole, then proceed.
  - TICKET_BUTTON_HANDLERS dispatch → requireStaffRole, then proceed.
  - FREE_BUTTON_HANDLERS (open_ticket* panel buttons, cancel_delete_tag)
    remain ungated — those are public-facing by design.

requireStaffRole replies ephemerally ("This command is only available
to the support team (<@&role>)") and returns true when the caller
should bail, matching the slash-command behavior.
2026-05-19 19:55:01 +00:00
452f005aea silence secondary-bot 429 fallback noise from debug channel
Two paired logWarn calls used to post to DEBUGGING_CHANNEL_ID every time
the RENAMER_BOT secondary token hit Discord's per-channel rename quota:

  Warning: renamer        — "429 rename channel=… retry_after=…"
  Warning: renameQueue    — "secondary-bot 429; falling back to primary…"

Both fire on the recoverable path: the channelQueue immediately falls
back to the primary discord.js client, and that client's REST handler
transparently waits out the retry_after and retries — the rename lands
without operator action. Posting these to the debug channel was pure
noise; staff were reading them as failures when nothing had failed.

Demoted both to console.warn so they still appear in `docker logs
broccolini` for diagnostic purposes but no longer post to Discord.

Kept untouched:
- utils/renamer.js:64 — 401/403 logWarn on secondary-bot auth/permission
  errors (real config problems; the operator does need to know).
- services/channelQueue.js next.catch logError for status 401/403/429 —
  only fires when the fallback itself also failed (rare and worth a
  debug-channel post).
2026-05-19 18:38:18 +00:00
76279b703a gmail-poll: lock email-ticket channels to staff role only
guild.channels.create in findOrCreateTicketChannel previously had no
permissionOverwrites — newly created email-ticket channels inherited
whatever the parent category granted. If the category ever had @everyone
View Channel allowed (or undefined → default-allow), every server member
could read every email ticket.

Add explicit overrides on creation:
- @everyone (guild.id): deny ViewChannel
- ROLE_ID_TO_PING: allow ViewChannel + SendMessages + ReadMessageHistory
  (gated on ROLE_ID_TO_PING being set — empty string skips the entry
  rather than creating a malformed overwrite).

Email tickets have no Discord creator (the customer reaches the bot via
email, not as a guild member) so the only "allow" entry is the staff
role. Modal-created and context-menu-created tickets already set
creator+role overrides on creation; this change brings the third path
into line.

Pairs with category-level Discord config: TICKET_CATEGORY_ID and the
ESCALATED2/3 categories should still deny @everyone and allow
ROLE_ID_TO_PING at the category level for defense in depth.
2026-05-19 18:26:12 +00:00
3c13e55dad audit week 3 quality batch: QUAL-004/005/007/008/010 + SEC-002
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).
2026-05-08 20:46:04 +00:00
3e9ad658d0 audit week 3 [SEC-004 + SEC-005]: scope members.fetch + redact PII in debug logs
[SEC-004] services/staffThread.js — addRoleMembersToThread previously called
the unscoped guild.members.fetch() on every ticket creation, chunking every
member of the guild. With STAFF_THREAD_AUTO_ADD_ROLE on and a 50-member
staff role, the 300ms-per-add loop also blocked ticket creation for ~15s.

  - Read role.members directly (computed from guild.members.cache, kept in
    sync via the GuildMembers gateway intent set on the client). Skip the
    explicit unscoped fetch in the hot path.
  - Cache-cold fallback: one scoped guild.members.fetch({ withPresences:
    false }) — irrelevant presence sync stays off the wire.
  - createStaffThread no longer awaits the add-loop. Wraps the call in
    setImmediate(...) so ticket creation returns immediately while the
    rate-limited add-loop runs in the background.

[SEC-005] services/debugLog.js — stacks/messages posted to the debug
channel could leak customer email addresses (interpolated through ticket
errors) and Discord member/channel IDs. Add a redactPII helper applied to
both logError's message + stack and logWarn's body:

  - Email regex /[\w.+-]+@[\w.-]+\.\w+/g → [EMAIL_REDACTED]
  - Discord snowflake /\b\d{18,20}\b/g → [ID_REDACTED]

interaction.user.tag in the User: line is intentionally not redacted —
it's needed for triage and is not PII (Discord usernames are public).
2026-05-08 20:42:48 +00:00
952b22ac12 audit week 3 [DEP-001]: upgrade mongoose 6.12 → 8.23
Mongoose 6 entered maintenance/EOL in 2023; current major is 8.x. No source
changes required — every API the codebase uses is identical between v6 and
v8:

- models.js schema DSL (Schema, default: Date.now function refs, enum,
  unique, required, index) is unchanged.
- db-connection.js connect options (serverSelectionTimeoutMS,
  socketTimeoutMS) and connection event names (error/disconnected/
  reconnected) are unchanged.
- All queries already use the v7+-required APIs: countDocuments (not the
  removed count()), updateOne/findOneAndUpdate (not the removed update()),
  bulkWrite, .lean() — no callback-based queries, no Model.remove(),
  no findOneAndRemove.
- findOneAndUpdate sites all explicitly pass { new: true } so the v7
  default-flip from old-doc to new-doc doesn't change behavior.
- strictQuery default flipped to false in v7+; codebase only filters on
  schema-defined fields, so the change is a no-op.

Verification on this commit:
- node -e "require('./db-connection')" loads cleanly; modelNames() returns
  the expected six models.
- Every source file under handlers/, services/, routes/, gmail-poll.js,
  broccolini-discord.js requires cleanly.
- new Ticket(...).validateSync() accepts valid docs, rejects invalid enum
  values, and Date defaults still fire.
- vitest run still passes (87/87) — pure-function suite is unaffected by
  the upgrade but confirms no regression in the dependency-shared modules.

Production verification (live DB CRUD: create/find/updateOne/deleteOne/
bulkWrite) still owed via docker compose up --build -d on the homelab.
2026-05-08 20:40:28 +00:00
d89ac65823 audit week 3 [TEST-001]: bootstrap vitest + utils & configSchema smoke tests
Adds vitest@^4.1.5 as a devDependency, an `npm test` script (runs once,
non-watch), and tests/ with 87 smoke tests across two suites:

- tests/utils.test.js (42 tests) — pure functions in utils.js:
  stripEmailQuotes, stripMobileFooter, extractRawEmail, escapeHtml,
  sanitizeEmbedText, truncateEmbedDescription, replaceVariables,
  getPriorityEmoji, safeEqual, isStaff. Covers normal input, empty input,
  null/undefined, edge cases (CRLF normalization, oversize truncation,
  triple-backtick escape, code-block injection).

- tests/configSchema.test.js (45 tests) — getValidator type inference and
  per-validator validate() behavior for boolean / integer / hex_color /
  url / email / discord_id / discord_id_list / string fallback. Covers
  ALLOWED_CONFIG_KEYS membership, the ROLE_ID_TO_PING mid-key override,
  legacy "true"/"false"/numeric coercion in the string fallback, empty
  input as ok-with-empty, garbage rejection.

vitest.config.mjs sets `environment: 'node'`, `globals: false`, and
`include: ['tests/**/*.test.js']`. Foundation for the mongoose 6→8
upgrade — these tests don't touch the DB but confirm pure-function
behavior is preserved across dependency moves.
2026-05-08 20:38:41 +00:00
adcd9dd9c9 audit week 2 [ARCH-001]: split handlers/commands.js into submodules
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.
2026-05-08 20:29:44 +00:00
d0cf8fd915 audit week 2 [VIBE-001]: decompose gmail-poll.js poll()
Split the original 309-line poll() into single-responsibility helpers and a
thin orchestrator. No behavior change — every Gmail API call, Discord call,
DB write, and log line stays in the same order with the same arguments.

Helpers extracted (module-private):
- locateGuild(client) — DISCORD_GUILD_ID lookup with fallback warning.
- parseGmailMessage(email) — header parsing, body decode, dual cleanup
  (firstBody for new-ticket message, followupBody for thread append).
- findOrCreateTicketChannel(guild, parsed, number) — category resolution
  + channel.create with the existing two-stage error handling.
- linkPreviousTranscripts(ticketChan, threadId, client) — best-effort
  prior-transcript link on reopen.
- markGmailMessageRead(gmail, msgRef) — wraps the batchModify call used
  in five places across the original.
- oauthSuspendIfPermanent(err, client) — invalid_grant/invalid_client
  classify, suspend polling, clear interval, DM admin once. Returns bool.

poll() is now the orchestrator: list → locate guild → for each message,
parse → look up existing ticket → branch (append-followup vs new-ticket
flow) → mark read. The new-ticket branch stays inline in poll() per the
"keep poll() as orchestration" intent.
2026-05-08 20:23:30 +00:00
cdf85f6364 audit week 1: creator ID tracking, channel-queue migration, deprecation cleanup
QUAL-006  store ticket.creatorId on creation; legacy split-pop returned the
          message ID for discord-msg-* tickets, breaking transcript DM, close
          log, and channel rename for context-menu-created tickets. Adds the
          field to the Ticket schema and writes a one-shot backfill script
          (scripts/backfill-creatorId.js, dry-run by default).

QUEUE-001 add enqueueOverwrite + enqueueTopic to services/channelQueue.js
          (chain on renameChains alongside enqueueMove). Migrate handleAdd /
          handleRemove / handleMove / handleTopic so permissionOverwrites,
          setParent, and setTopic no longer race pending renames or sends.
          handleMove now uses the existing enqueueMove. Initial overwrites in
          handleTicketModal stay inline; channel doesn't exist yet so no race.

DISCORD-001 replace ephemeral: true with flags: MessageFlags.Ephemeral across
            broccolini-discord.js, handlers/sharedHelpers.js, handlers/buttons.js,
            handlers/commands.js. runDeferred opts now take { flags } directly.

SEC-003   /gmailpoll min interval is 30s. Drop the 5s/10s slash-command
          choices and clamp Math.max(30000, ms) in handleGmailPoll for
          defense in depth.

QUAL-001  upgrade silent .catch(() => {}) on the lastActivity updateOne in
          handlers/messages.js to log via logError, so transient Mongo errors
          surface in the debug channel instead of disappearing.

QUAL-002  drop await from logError/logWarn calls in services/staffThread.js
          and services/pinMessage.js — fire-and-forget per CLAUDE.md hard rule.

QUAL-003  wrap stray setTimeouts (handleConfirmCloseRequest force-close timer,
          runFinalClose channel-delete + overflow-cleanup, checkAutoClose
          delete-after-email) in trackTimeout via lazy require so they clear
          on shutdown.
2026-05-08 20:19:14 +00:00
e3b3b8d48c refactor handleButton into a dispatch table
Each customId now maps to a named handler in one of two tables:
FREE_BUTTON_HANDLERS (open-ticket panel, tag-delete cancel — no ticket
lookup) or TICKET_BUTTON_HANDLERS (anything fired inside a ticket channel
— the dispatcher does the lookup once before delegating). The dynamic
`confirm_delete_tag::*` id is matched by prefix.

To find a button's logic, search handle<Name>Button or handleTagDelete*.

Other cleanups in the same pass:
- Move findTicketForChannel and runDeferred from handlers/commands.js to
  the new handlers/sharedHelpers.js so both files share one source of
  truth. runDeferred now also calls logError(verb, ...) — was logged ad
  hoc in buttons.js, missing in commands.js. Strictly additive.
- Hoist three inline `require('../services/...')` calls (staffThread,
  pinMessage, debugLog) to top imports.
- Collapse escalate_to_tier2 and escalate_to_tier3 into one
  handleEscalateButton(interaction, ticket) that derives the tier from
  customId. Same for confirm_close / confirm_close_with_email /
  confirm_close_no_email — one handleConfirmCloseRequest deriving
  sendEmail from customId.
- Decompose the 156-line handleConfirmClose into runFinalClose +
  buildTranscriptText + formatDateForTranscript + renderTranscriptHeader
  + dmTranscriptToCreator + postCloseLogEntry. Each piece is testable in
  isolation.
- Decompose handleClaim into applyClaim + applyUnclaim.
- Extract buildOpenTicketModal() and postTicketWelcomeEmbeds() so the
  ticket-creation modal flow is readable top-to-bottom.

No behavior change. handleButton + handleTicketModal exports preserved;
24/24 modules load clean (sharedHelpers.js is the new one).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:57:43 +00:00
3ac23466b2 refactor handleCommand into a dispatch table
Each slash command and context-menu entry is now its own named function;
handleCommand looks the name up in COMMAND_HANDLERS and delegates. Finding
where a command lives is now "grep handle<Name>" instead of scrolling 600
lines of sequential ifs.

Other cleanups in the same pass:
- Restore ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle
  to the top-level discord.js destructure. ActionRowBuilder was used at
  runtime by /response delete and /panel but had been dropped from the
  imports based on a stale "unused" diagnostic — those paths would have
  thrown ReferenceError. Hoisting the previously-inline /signature
  imports as well.
- Hoist `logTicketEvent` to the top imports (was inline-required at three
  callsites).
- Extract findTicketForChannel(), runDeferred(), and fetchLoggingChannel()
  helpers — replaces the lookup-then-defer-then-try-catch boilerplate
  repeated in nearly every branch.
- Pull the force-close timer body into finalizeForceClose() and
  postTranscript() so the timer registration is one line.
- Pull the panel button-row construction into buildPanelButtonRow().
- Split /response into its own RESPONSE_SUBCOMMANDS dispatch + per-sub
  handlers.
- Consolidate the duplicated transcript date formatter into one local fmt().

No behavior change. All 23 modules still load clean; handleCommand,
handleContextMenu, handleAutocomplete, runEscalation, and runDeescalation
exports are preserved (handlers/buttons.js imports the last two).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:51:29 +00:00
83b6b4ae0c simplify: rename CONFIG channels, dedup hasStaffRole, drop enforceEmbedLimit
- Rename CONFIG.TRANSCRIPT_CHAN -> CONFIG.TRANSCRIPT_CHANNEL_ID and
  CONFIG.LOG_CHAN -> CONFIG.LOGGING_CHANNEL_ID across 9 callsites so
  CONFIG keys match their .env names — no more "grep .env, find nothing"
  for new readers
- Replace handlers/commands.js#hasStaffRole with utils.js#isStaff
  (was a verbatim copy)
- Delete utils.js#enforceEmbedLimit and its 2 callsites; both inputs are
  bounded well under the 6000-char Discord embed cap, so the trim was
  defensive code that never fired

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:45:18 +00:00
840b6bfcf8 simplify: prune dead code, dedup gmail send, drop neutered log stubs
- Remove no-op log stubs (logGmail, logAutomation, logSecurity, logSystem)
  and ~17 callsites; dead counters in tickets.js and gmail-poll.js go too
- Dedup three near-identical Gmail send paths into sendThreadedEmail helper
- Drop dead Mongoose fields: broccoliniTicketId, lastSyncedBroccoliniArticleId,
  renameCount, renameWindowStart, reminderSent, staffChannelId,
  unclaimedRemindersSent, lastMessageAuthorIsStaff
- Drop dead config fields and their .env.example entries
- Inline api/botClient.js (3-line wrapper, 2 callers)
- Trim unused exports across utils.js, tickets.js, configSchema.js, debugLog.js
- Fix handlers/messages.js to use isStaff() — old partial check ignored
  ADDITIONAL_STAFF_ROLES, so those members were treated as customers
- Drop unused deps p-queue + dotenv-expand; move mongodb to devDependencies

Net: -583 LOC source + -57 LOC lockfile. All 23 modules load clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:37:14 +00:00
d5547e5eea remove stale docs/ tree and gitignore comment
8 untracked .bak*/.dubignore files and empty docs/architecture, docs/setup, docs/ also removed from disk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:20:47 +00:00
602c6c0191 remove diff-paste garbage files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:19:44 +00:00
6b94791813 cleanup and simplify
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 02:15:18 +00:00
d1e1408256 email fixes 2026-04-21 20:54:49 +00:00
8e362c607d mvp: neuter debug log helpers, strip config keys, update tagline 2026-04-21 20:18:59 +00:00
410f8b043e fix: add Escalate button to email ticket welcome embed
gmail-poll.js was hand-rolling its own Close+Claim-only action row.
Replaced with getTicketActionRow({ escalationTier: 0 }) — same helper
panel-created tickets use. Email and Discord tickets now show identical
buttons on creation: Close, Claim, Escalate.
2026-04-21 19:50:11 +00:00
2a04e3964c trim settings-site to match stripped bot
- delete public/js/notifications.js (521 LOC)
- remove notifications/patterns/surge/chat/priority/bosscord/accountinfo/threads UI sections
- remove 3 /api/notifications/* proxy routes from server.js
- untrack settings-site backup files from git
- ~926 LOC removed from settings-site
2026-04-21 19:34:10 +00:00
60c302276b more removals 2026-04-21 17:32:00 +00:00
ce62b7a94a cleanup: untrack .bak3 backups; dedupe + broaden bak gitignore pattern 2026-04-21 17:26:35 +00:00
f3ee27ed7a more mvp strip 2026-04-21 17:24:03 +00:00
6d579207f3 untrack CLAUDE.md, settings-site/CLAUDE.md, .claude/ (local-only)
Added CLAUDE.md to .gitignore (unanchored — matches nested too).
2026-04-21 17:21:39 +00:00
431622d05c untrack .claude/ local settings 2026-04-21 17:20:19 +00:00
b1f66107c0 untrack CLAUDE.md 2026-04-21 17:20:09 +00:00
b764bc98c7 untrack CLAUDE.md 2026-04-21 17:19:39 +00:00
51 changed files with 4804 additions and 5580 deletions

View File

@@ -1,53 +0,0 @@
{
"permissions": {
"allow": [
"Bash(node -e ':*)",
"Bash(node --check services/tickets.js)",
"Bash(node --check config.js)",
"Bash(node --check handlers/commands.js)",
"Bash(node --check handlers/buttons.js)",
"Bash(node --check gmail-poll.js)",
"Bash(node --check handlers/pendingCloses.js)",
"Bash(node --check commands/register.js)",
"Bash(grep -E \"\\\\.js$|\\\\.json$|^d\")",
"Bash(grep -r \"require\\\\|import\" /opt/broccolini-bot/*.js /opt/broccolini-bot/routes/*.js /opt/broccolini-bot/api/*.js)",
"Bash(grep -r \"require\\\\|import\" /opt/broccolini-bot/*.js)",
"Bash(grep *)",
"Bash(npm install *)",
"Bash(node -e \"require\\('./routes/bosscord'\\)\")",
"Bash(node -e \"require\\('./routes/internalApi'\\)\")",
"Bash(node -e \"require\\('express-rate-limit'\\)\")",
"Bash(node --check services/surgeChecker.js)",
"Bash(node --check services/patternChecker.js)",
"Bash(node --check services/chatAlertChecker.js)",
"Bash(node --check services/debugLog.js)",
"Bash(node --check services/staffChannel.js)",
"Bash(node --check handlers/messages.js)",
"Bash(node *)",
"Bash(npm info *)",
"Bash(npm ls *)",
"Bash(curl -sk http://127.0.0.1:12752/css/main.css)",
"Bash(curl -sk http://127.0.0.1:12752/js/app.js)",
"Bash(curl -sk -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://127.0.0.1:12752/css/main.css)",
"Bash(curl -sk -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://127.0.0.1:12752/js/app.js)",
"Bash(docker compose *)",
"Bash(curl -sI http://100.114.205.53:12752/healthz)",
"Bash(curl -s http://100.114.205.53:12752/healthz)",
"mcp__plugin_context7_context7__query-docs",
"Bash(curl -sI http://100.114.205.53:12752/api/config)",
"Bash(curl -sI http://100.114.205.53:12752/)",
"Bash(curl -sI http://100.114.205.53:12752/login)",
"Bash(curl -sI http://100.114.205.53:12752/some/nonexistent/path)",
"Bash(curl -sI http://100.114.205.53:12752/js/app.js)",
"Bash(curl -sI http://100.114.205.53:12752/js/util.js)",
"Bash(curl -sI http://100.114.205.53:12752/js/notifications.js)",
"Bash(docker exec *)",
"Bash(curl -sI http://100.114.205.53:12752/api/notifications/alerts)",
"Bash(git log *)",
"Bash(curl *)",
"Bash(docker inspect *)",
"Bash(git -C /opt/broccolini-bot tag)",
"Bash(git -C /opt/broccolini-bot branch)"
]
}
}

View File

@@ -13,13 +13,9 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs
# Ticket creation: set one or both; /panel and /email-routing choose behavior
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
TICKET_CATEGORY_ID= # Category for email-originated ticket channels
DISCORD_THREAD_CHANNEL_ID= # Text channel for Discord ticket threads (optional)
EMAIL_THREAD_CHANNEL_ID= # Text channel for email ticket threads (optional)
# Category display names (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
# Category display name (primary must match the category name in Discord; overflow folders are created as "{name} (Overflow 1)", etc.)
TICKET_CATEGORY_NAME=Open Tickets
TICKET_T2_CATEGORY_NAME=Tier 2 Escalated Tickets
TICKET_T3_CATEGORY_NAME=Tier 3 Escalated Tickets
# Escalation categories (tier 2 and tier 3)
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
@@ -34,7 +30,6 @@ ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also
TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
DISCORD_CHANNEL_ID= # General Discord channel (if used)
# --- Discord: Ticket copy & buttons ---
# ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME
@@ -52,8 +47,7 @@ GOOGLE_CLIENT_SECRET= # OAuth2 Client Secret
REFRESH_TOKEN= # OAuth2 refresh token for the support inbox
MY_EMAIL= # Support inbox email address
# --- Server & URLs ---
NGROK_URL= # Public URL (optional); run ngrok outside this repo
# --- Server ---
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
@@ -76,7 +70,6 @@ DISCORD_AUTO_CLOSE_MESSAGE= # Message in ticket when auto-closed (e.g. ...
# --- Ticket limits & permissions ---
GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally
TICKET_LIMIT_PER_CATEGORY=3 # Max tickets per category
RATE_LIMIT_TICKETS_PER_USER=0 # Max tickets per user per window (0 = disabled)
RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit
BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets
@@ -85,7 +78,6 @@ ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissi
# --- Auto-close ---
AUTO_CLOSE_ENABLED=false
AUTO_CLOSE_AFTER_HOURS=72
AUTO_CLOSE_MESSAGE= # Message when ticket is auto-closed
# --- Reminders ---
REMINDER_ENABLED=false
@@ -137,16 +129,11 @@ SETTINGS_DOMAIN=tickets.indifferentketchup.com # Domain for the settings site (
INTERNAL_API_PORT=12753 # Internal port for bot<->settings IPC (not exposed externally)
INTERNAL_API_SECRET= # Shared secret between bot and settings site (generate a random string)
# --- Thread-style tickets (legacy) ---
USE_THREADS=false
THREAD_PARENT_CHANNEL=
# --- Game list (comma-separated; used for detection and tags) ---
GAME_LIST=Project Zomboid, Minecraft, ...
# --- Embed colors (hex with 0x prefix) ---
EMBED_COLOR_OPEN=0x00FF00
EMBED_COLOR_CLOSED=0xFF0000
EMBED_COLOR_CLAIMED=0xFFFF00
EMBED_COLOR_ESCALATED=0xFF6600
EMBED_COLOR_INFO=0x1e2124

8
.gitignore vendored
View File

@@ -1,8 +1,6 @@
# Dependencies
node_modules/
# Documentation: docs/ is committed (all .md except README.md live in docs/)
# Environment / Secrets (keep .env.example and .env.test.example committed; never commit .env or .env.test)
.env
.env.*
@@ -49,8 +47,6 @@ cursor.yml
*.local.yml
.claude/
*.bak
*.bak-*
*.bak
*.bak-*
CLAUDE.md
*.bak*

129
CLAUDE.md
View File

@@ -1,129 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Default mode: reviewer-first
Default output is **scoped improvement prompts**, not code edits. Output format:
```
## [Short title]
**Files:** [files to read/modify]
**Problem:** [1-2 sentences]
**Fix:** [specific instructions — what to change, not how to think about it]
**Verify:** [how to confirm]
```
Keep each prompt to a 520 minute task; decompose larger issues.
When the user asks for direct fixes, make them — but still avoid unsolicited refactors, rename sweeps, or cleanup beyond the stated scope.
## Project
- **broccolini-bot** — Discord ticketing + support bot for Indifferent Broccoli (game hosting).
- **Repo:** `/opt/broccolini-bot/` · **Gitea:** `ssh://git@100.114.205.53:2222/indifferentketchup/broccolini-bot.git`
- **DB:** Self-hosted MongoDB on same host as bot, database `broccoli_db`. Dedicated user per DB.
- **Host port 8892 → container port 5000** (`CONFIG.PORT`, env `DISCORD_ONLY_PORT`).
- **Deploy:** `cd /opt/broccolini-bot && git pull && docker compose up --build -d` · tail: `docker logs broccolini-bot --tail 50 -f`
## Commands
- `npm start` — run the bot (entry: `broccolini-discord.js`).
- `npm run start:test` — run with `ENV_FILE=.env.test`.
- `npm run start:1p` / `start:test:1p` — inject secrets via 1Password CLI (`op run`).
- `npm run test-mongodb` / `test-mongodb:test` — connectivity probe; no test suite exists.
- No lint step configured. No unit/integration test framework.
- **Verification:** prefer `node --check <file>` for syntax, and inline `node -e "..."` for behavior. For tightly-coupled modules, stub deps via `Module._resolveFilename` override (see `services/channelQueue.js` tests in session history).
Many files under `scripts/` are one-shot maintenance utilities (backups, user lookups, transcript mapping). They are not wired into CI or into the bot's runtime.
## Stack
Node.js **CommonJS** · Discord.js 14 · Express 5 · Mongoose 6 · googleapis · express-rate-limit · p-queue · dotenv/dotenv-expand.
## Hard Rules
1. **CommonJS only.** `require` / `module.exports`. Never `import`.
2. **Read before write.** Never propose or make changes to a file without first reading its current contents.
3. **Route channel operations through `services/channelQueue.js`**: `enqueueSend`, `enqueueRename`, `enqueueMove`, `enqueueDelete` (awaits both rename+send tails before deleting). Bypass sites are tagged `// TODO(queue-migrate):` — grep to find them; migrate incrementally when touching.
4. **Logging is fire-and-forget.** Never `await logSystem/logError/logAutomation/logGmail/...`. Chain `.catch(() => {})` instead.
5. **Use `ChannelType` enum from `discord.js`**, not bare integers (`0`, `4`, `5`, `12`, `15`).
6. **Mongoose schema defaults:** pass function references (`default: Date.now`), never invocations (`default: Date.now()` pins all documents to module-load time).
7. **No unsolicited refactors.** Don't rename, reorganize, or restructure beyond the fix's scope.
8. **Backup before destructive data ops.** Provide the backup command first when the fix touches collections/files.
## Architecture
Single Node process. Entry: `broccolini-discord.js`.
### Startup order
1. Module load: env validation, Discord `Client` created, `interactionCreate` / `messageCreate` listeners registered, `client.login(...)` called.
2. Public Express app (`app`) is defined at module scope with a **503 gate** — any `/api/*` request before `appReady` returns 503.
3. `client.once('ready')` (fires after Discord handshake): connects MongoDB, mounts bOSScord routes on `/api` (only if `BOSSCORD_API_KEY` set), calls `app.listen(CONFIG.PORT, CONFIG.HEALTHCHECK_HOST)`, sets `appReady = true`, then starts all background `setInterval`s.
4. The **internal** Express app (`internalApp`) listens separately on `0.0.0.0:INTERNAL_API_PORT` inside the bot container at module load, guarded by `INTERNAL_API_SECRET`. Not publicly exposed — reachable only from peers on the `broccoli-net` docker network (notably the settings-site container).
### Two HTTP surfaces
- **Public (`app`)** — `GET /` healthcheck + `/api/*` (bOSScord consumer). CORS origin is `process.env.BOSSCORD_CLIENT_ORIGIN` (default `http://100.114.205.53:3081`). Rate-limited 60 req/min/IP. Auth: `Authorization: Bearer ${BOSSCORD_API_KEY}`.
- **Internal (`internalApp`)** — `broccoli-net` only (binds `0.0.0.0` inside the bot container; no host `ports:` publish), `/internal/*`. Rate-limited 10 req/min. Auth: `x-internal-secret` header. `POST /config` enforces an explicit `ALLOWED_CONFIG_KEYS` allowlist; unknown keys return 400. `POST /restart` exits the process so the container supervisor restarts it.
`routes/internalApi.js` is required at module scope by `broccolini-discord.js` *before* the parent's `module.exports` populates — reaching back to the parent (e.g., `trackInterval`, `trackTimeout`, `clearGmailPollInterval`) must use a **lazy `require('../broccolini-discord')` inside the handler**, not a top-level destructure.
### Intervals & shutdown
- Every `setInterval` inside `ready` is wrapped via `trackInterval(...)` into the module-scoped `activeIntervals` Set.
- `handleShutdown(signal)` is idempotent (`shuttingDown` flag): clears every tracked interval, closes both HTTP servers, calls `client.destroy()`, calls `closeMongoDB()`, then `process.exit(0)`. Wired to SIGTERM/SIGINT.
- `setGmailPollInterval(ms)` and `clearGmailPollInterval()` manage the Gmail poll handle and keep it in sync with `activeIntervals`.
### Interaction error handling
Every `interactionCreate` branch runs through `runHandler(name, interaction, fn)` which catches, `logError`s, and replies ephemerally `'Something went wrong.'` (uses `followUp` when the interaction is already deferred/replied). Setup buttons have their own try/catch for a custom error message.
### Tickets (`services/tickets.js`, `models.js`)
- `Ticket` schema has indexes on `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`.
- **Discord-originated tickets** use `gmailThreadId` with prefix `discord-` / `discord-msg-` — skip the Gmail reply path entirely.
- Renames route through `utils/renamer.js` (RENAMER_BOT secondary token). On 401/403/429 from the secondary, `services/channelQueue.js` falls back to the primary bot via `channel.setName`. `canRename()` in `services/tickets.js` is retained as an always-ok shim for back-compat. `Ticket.renameCount` / `Ticket.renameWindowStart` remain in the schema but are now unread/unwritten orphan fields.
- `getOrCreateTicketCategory()` handles Discord's 50-channels-per-category ceiling by creating `"<name> (Overflow N)"` categories; `cleanupEmptyOverflowCategory()` removes empties. The primary category is never deleted.
- Scheduled jobs in `ready`: `checkAutoClose`, `checkAutoUnclaim`, `reconcileDeletedTicketChannels`, plus `services/staffNotifications.js#notifyAllStaffUnclaimed` and the pattern/surge/chat checkers.
### Gmail bridge (`gmail-poll.js`, `services/gmail.js`)
- Polls `is:unread category:primary`, creates or appends to ticket channels.
- **Auth failure halts polling.** On `invalid_grant` / `unauthorized` / 401: `pollSuspended = true`, the poll interval is cleared via `require('./broccolini-discord').clearGmailPollInterval()`, admin is DM'd once. Polling does **not** auto-retry — container must restart after re-auth.
- `services/gmail.js` exports `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail`, `getGmailClient`. All HTML bodies go through `escapeHtml()`; `Date.now`-derived variables in templates come from `CONFIG` (`TICKET_CLOSE_MESSAGE`, `TICKET_CLOSE_SIGNATURE`, `SUPPORT_NAME`, `LOGO_URL`, `SIGNATURE`, `EMAIL_SIGNATURE`).
### Pattern / surge / chat (`services/patternStore.js` et al.)
- In-memory counters bucketed into `today` / `week` / `month`, with scheduled resets at midnight / Monday 00:00 / 1st 00:00.
- `escalatingCooldowns` entries carry a `lastUsed` timestamp; a 6-hour interval prunes entries idle for >48h. The cleanup interval is `.unref()`-ed so shutdown isn't blocked by it.
### `.env` persistence (`services/configPersistence.js`)
- Values are stored in **backtick** containers because dotenv v17 only decodes `\n`/`\r` inside `"…"` (not `\"` or `\\`) — backticks preserve quotes + literal newlines verbatim. `readEnvFile` joins multi-line backtick values; `writeEnvFile` re-reads after write and throws on key-count mismatch.
## bOSScord integration
bOSScord is a separate React + Express cockpit app that consumes this bot's `/api/*` endpoints.
- Base URL: `http://100.114.205.53:8892/api` · Bearer `${BOSSCORD_API_KEY}`.
- bOSScord uses its own database (`bosscord_db`) — do not mix models.
- **Response-shape changes on `/api/*` are breaking** for bOSScord. Coordinate or version.
## Known bad state
- **Gmail `invalid_grant`** — `REFRESH_TOKEN` is a stale placeholder. Poll suspends automatically on auth error; the rest of the bot still works. Fix by regenerating the token (`node get-refresh-token.js`) and restarting.
- **`STAFF_EMOJIS` encoding** — some emoji entries render malformed. Root cause not identified.
- **Escalation button** — handler misfires in some flows. Root cause not identified.
Do not re-report these as new findings.
## Environment highlights
Names and full tables are in `README.md` / `.env.example`. Ones that commonly trip up new code:
| Var | Notes |
|-----|-------|
| `DISCORD_TOKEN` **or** `DISCORD_BOT_TOKEN` | First non-empty after trim wins. |
| `DISCORD_ONLY_PORT` | Maps to `CONFIG.PORT` (default 5000). |
| `HEALTHCHECK_HOST` | Omit for all-interfaces; set `127.0.0.1` for local-only. |
| `BOSSCORD_API_KEY` | Without it, `/api/*` is never mounted. |
| `BOSSCORD_CLIENT_ORIGIN` | CORS origin for bOSScord (not `BOSSCORD_CORS_ORIGIN`). |
| `INTERNAL_API_SECRET` | Without it, the internal settings API is never started. |
| `INTERNAL_API_PORT` | Internal app's port (127.0.0.1 bind). |
| `REFRESH_TOKEN` | Gmail OAuth; currently stale — see Known bad state. |
## Settings site
`settings-site/` contains a separate Express app (`settings-site/server.js`) for the admin UI — it talks to `internalApp` via `INTERNAL_API_SECRET`. It is **not** part of this bot's process. Changes to the bot's `/internal/config` contract (e.g., the `ALLOWED_CONFIG_KEYS` set) may break the settings UI. See `settings-site/CLAUDE.md` for that subproject's architecture and conventions.
Its Docker build context is `settings-site/` only — parent-repo files (e.g., `utils.js`) are unreachable inside the container. Any shared helper must be inlined or the build context widened in `docker-compose.yml`.

120
HOWITWORKS.md Normal file
View File

@@ -0,0 +1,120 @@
# How it works
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.
2. **Polls Gmail** — every N seconds, pulls unread `category:primary` 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.
State lives in MongoDB via Mongoose. There is no queue/worker tier and no public REST API.
---
## Startup
`broccolini-discord.js` is the entry point. The order matters:
1. **Module load** — env validation, Discord `Client` is constructed, `interactionCreate` and `messageCreate` listeners are registered, `client.login()` is called.
2. The **public Express app** is defined at module scope. It has a 503 gate — any `/api/*` request before the bot is ready returns 503. (No `/api/*` routes are mounted in the MVP, so it's dormant.)
3. The **internal Express app** binds at module load on `INTERNAL_API_PORT` (`0.0.0.0` inside the container, not host-published). Reachable only from peers on the `broccoli-net` Docker network. Auth header: `x-internal-secret`.
4. **`client.once('ready')`** — once Discord finishes its handshake the bot connects MongoDB, starts the public HTTP listener, registers slash commands with Discord's REST API, then starts the Gmail poll plus optional `setInterval`s for auto-close, auto-unclaim, sweeping orphan ticket channels, and a 6-hour Tickets sweep.
Every `setInterval` in the `ready` block is wrapped through `trackInterval(...)` into a module-level `Set`. `handleShutdown` (SIGTERM/SIGINT) clears all of them, closes both HTTP servers, calls `client.destroy()`, then closes Mongo.
---
## Email → Discord (the main flow)
`gmail-poll.js`:
1. Lists unread messages in `category:primary`.
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`.
4. On subsequent emails in the same thread, just appends the new message to the existing channel.
**Auth failure halts polling.** On `invalid_grant` / `unauthorized` / 401, `pollSuspended` flips to true, the poll interval is cleared, and the admin is DM'd once. The bot does not retry — fix the token and restart the container.
---
## Discord → Gmail (replies)
`handlers/messages.js` handles `messageCreate` in ticket channels:
- If the ticket's `gmailThreadId` starts with `discord-` or `discord-msg-`, it's a Discord-only ticket — skip Gmail entirely.
- Otherwise, the staff message is forwarded to the customer via Gmail (threaded reply) using `services/gmail.js`. The staffer's per-user signature (`StaffSignature`) is appended.
Customer replies coming back via email are picked up by the next Gmail poll and appended to the existing ticket channel.
---
## Discord-only tickets
Two paths create them:
- **`/panel`** posts an "Open ticket" button. Clicking it opens a modal asking for email, game, and description. The bot creates a channel and a `Ticket` with a synthetic `gmailThreadId` like `discord-<channelId>`.
- **Context menu "Create Ticket From Message"** does the same, prefilled from the source message (`gmailThreadId` like `discord-msg-<msgId>`).
Replies in these channels stay in Discord. No Gmail traffic.
---
## Channel rename / move queue
Discord rate-limits channel renames at **2 per 10 minutes per channel**. All channel ops route through `services/channelQueue.js`:
- `enqueueRename`, `enqueueSend`, `enqueueMove`, `enqueueDelete` — per-channel chained promises. Delete waits for both rename and send tails to drain.
- Renames go through `utils/renamer.js`, which uses the `RENAMER_BOT` secondary token. On 401/403/429 from the secondary, the queue falls back to the primary bot's `channel.setName`.
- Bypass sites (direct `channel.send` / `setName`) are tagged `// TODO(queue-migrate):` — grep to find them; they get migrated incrementally when touched.
Logging helpers in `services/debugLog.js` are **fire-and-forget**`.catch(() => {})`, never `await`. They post to the configured log channels (system, automation, error, gmail, etc.).
---
## HTTP surfaces
**Public** (`app`, port `CONFIG.PORT`, default `5000`):
- `GET /``Active` once ready, `Starting` before. Used by Docker `HEALTHCHECK`.
- `/api/*` is gated behind `appReady` and currently unmounted.
**Internal** (`internalApp`, `INTERNAL_API_PORT`, `broccoli-net` only, header auth):
- `GET /config`, `POST /config` — read/write a strict allowlist (`ALLOWED_CONFIG_KEYS` in `services/configSchema.js`). Unknown keys → 400.
- `GET /discord/guild` — basic guild info for the settings UI.
- `POST /restart`, `GET /restart/status` — exits the process so the container supervisor restarts it.
- `POST /gmail/reload` — reloads the Gmail client after credential changes.
`.env` writes go through `services/configPersistence.js`, which stores values in **backtick** containers because dotenv v17 only decodes `\n`/`\r` inside double-quoted strings.
---
## Storage
MongoDB, one database, accessed via Mongoose. Models live in `models.js`:
| Collection | What it stores |
|------------|----------------|
| `Ticket` | One per email thread or Discord-only ticket. Tracks `gmailThreadId`, `discordThreadId`, status, claimer, priority, escalation tier, ticket tag, last activity, etc. |
| `TicketCounter` | Per-sender local counter for ticket numbering. |
| `Transcript` | Closed ticket → transcript message pointer. |
| `Tag` | Saved-response templates (`/response`). |
| `StaffSettings` | Per-user `notifyDm` preference. |
| `StaffSignature` | Per-user email signature (valediction, display name, tagline). |
The `Ticket` schema indexes `{gmailThreadId}` (unique), `{status, lastActivity}`, `{senderEmail, status}`, `{discordThreadId}`.
---
## Background jobs (in `ready`)
| Job | Cadence | Toggle |
|-----|---------|--------|
| Gmail poll | `GMAIL_POLL_INTERVAL_SECONDS` (default 30s; runtime-tunable via `/gmailpoll`) | always |
| `checkAutoClose` | configurable | `AUTO_CLOSE_ENABLED` |
| `checkAutoUnclaim` | configurable | `AUTO_UNCLAIM_ENABLED` |
| `reconcileDeletedTicketChannels` | hourly + on startup | always |
| `services/tickets.startTicketsSweeps` | 6h, `.unref()`-ed | always |
---
## Settings UI (separate process)
`settings-site/` is its own small Express app with its own `package.json`, `Dockerfile`, and `CLAUDE.md`. It serves a password-protected dashboard that POSTs config changes to this bot's internal API using `INTERNAL_API_SECRET`. Any change to `ALLOWED_CONFIG_KEYS` here can break the UI there — keep them in sync.

555
README.md
View File

@@ -1,521 +1,90 @@
# Broccolini Bot
A **Node.js** Discord bot that unifies **Gmail**, **Discord**, and **MongoDB** for support ticketing. Incoming emails become Discord ticket channels; staff messages in those channels are sent back to customers via Gmail. Discord-originated tickets (panels, context menu) live entirely in Discord. State is stored in MongoDB via Mongoose.
A Node.js Discord bot that bridges **GmailDiscord** for support ticketing, with **MongoDB** for state. Built for Indifferent Broccoli (game-server hosting).
Built for game-server hosting support (Indifferent Broccoli), with game detection from email content, tiered escalation, optional **per-staff notification channels** (reply alerts with cooldown + unclaimed-ticket digests), saved responses, `/tag` categorization, claimer emoji in channel names (`STAFF_EMOJIS`), and automation (auto-close, reminders, auto-unclaim, claim timeout).
- Inbound email → Discord ticket channel.
- Staff messages in that channel → Gmail reply (threaded).
- Discord-originated tickets (panel / context menu) live entirely in Discord.
**Jump to:** [Features](#features) · [Quick start](#quick-start) · [Configuration](#configuration) · [Staff notifications](#staff-notification-channels--reply-alerts) · [Broccolini settings page](#broccolini-settings-page) · [Commands](#discord-commands) · [Project layout](#project-structure)
---
## Table of contents
- [Features](#features)
- [Architecture](#architecture)
- [Prerequisites](#prerequisites)
- [Quick start](#quick-start)
- [Installation](#installation)
- [Configuration](#configuration)
- [Staff notification channels & reply alerts](#staff-notification-channels--reply-alerts)
- [Broccolini settings page](#broccolini-settings-page)
- [Running the bot (test and Docker)](#running-the-bot-test-and-docker)
- [Discord commands](#discord-commands)
- [Ticket UI (buttons & modals)](#ticket-ui-buttons--modals)
- [Tag & response system](#tag--response-system)
- [Panel system](#panel-system)
- [Channel renames & moves (rate limits)](#channel-renames--moves-rate-limits)
- [Project structure](#project-structure)
- [Database collections](#database-collections)
- [HTTP: healthcheck & optional API](#http-healthcheck--optional-api)
- [Gmail OAuth refresh token](#gmail-oauth-refresh-token)
- [Documentation in `docs/`](#documentation-in-docs)
- [Development & CI](#development--ci)
- [Troubleshooting](#troubleshooting)
- [References](#references)
- [License](#license)
---
## Features
### Email → Discord
- Polls Gmail about every **30 seconds** for new unread **primary** mail (`gmail-poll.js`).
- Creates a **Discord text channel** (or thread, per guild settings) per email ticket, with overflow category support when a category is full.
- Detects **game** from subject/body using `GAME_LIST` and built-in aliases in `config.js`.
- Posts welcome + action **buttons** (Close, Claim, Escalate / De-escalate where applicable).
### Discord → Gmail
- For **email-sourced** tickets, staff messages in the ticket channel are **forwarded** to the customer via Gmail (threaded).
- **Discord-only** tickets (`gmailThreadId` prefix `discord-` / `discord-msg-`) do not use Gmail for replies; conversation stays in Discord.
### Ticket management
- **Claim / Unclaim** via buttons (not slash commands); optional claim overwrite, **claim timeout**, and auto-unclaim.
- **Priority** (`low` / `normal` / `medium` / `high`) with configurable emojis and `/priority`.
- **Escalation**: tier 2 and tier 3 categories (separate IDs for email vs Discord where configured); slash `/escalate` and in-channel buttons.
- **De-escalation** one step at a time (`/deescalate` or button).
- **Close** with confirmation; **force-close** for admins.
- **Transcripts** posted to a configured channel; closure email for email tickets.
- **Auto-close**, **inactivity reminders**, **auto-unclaim** (all optional via env).
### Staff notifications & alerts (optional)
- **Per-staff notification channels**: **`/notification add`** creates a **dedicated text channel** per staff member under `STAFF_NOTIFICATION_CATEGORY_ID` and stores it in **`StaffNotification`** (MongoDB). When a **non-staff** user replies in a ticket claimed by someone with a notification channel, the bot posts an alert there (subject to **per-ticket cooldown** via `/notification set` or admin **`/staffnotification`**).
- **Unclaimed digests**: a background job runs **every 30 minutes** and, if `UNCLAIMED_REMINDER_THRESHOLDS` is set, posts **unclaimed ticket** digests to those same channels when tickets cross age thresholds.
- **DM reply alerts**: **`/notifydm`** toggles optional **DM** alerts to the claimer on customer reply (separate from the notification channel); stored in **`StaffSettings`**.
- **Staff threads** (optional): when `STAFF_THREAD_ENABLED` is true, each ticket channel can get a private **staff-only thread** named `STAFF_THREAD_NAME`; on claim, the claimer can be added to that thread, and (optionally) all members of `STAFF_THREAD_ROLE_ID` are auto-added.
- **Pins** (optional): `PIN_INITIAL_MESSAGE_ENABLED` and `PIN_ESCALATION_MESSAGE_ENABLED` enable auto-pinning of the ticket welcome message and escalation messages; `PIN_SUPPRESS_SYSTEM_MESSAGE` hides the default “X pinned a message” system notice.
- **Chat monitoring & surge detection**: see [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for automatic alerts about busy chats, surging games, backlogs, and no-staff situations.
See [Staff notification channels](#staff-notification-channels--reply-alerts) and [Patterns, surge & chat alerts](#patterns-surge--chat-alerts) for details.
**Note:** Older docs referred to per-staffer **mirror** channels driven by `STAFF_CATEGORIES`. In current `config.js` that map is **deprecated and always empty**, and **`createStaffChannel` is not called** from the claim flow—**`staffChannelId` on tickets is effectively unused.** Reply alerts use **`StaffNotification`** channels instead, and staff discussion happens in optional **staff threads**.
### Extras
- **`/panel`**: “Open ticket” UI (modal collects email, game, description).
- **`/tag`**: ticket category dropdown; **`/response`**: saved templates with variable substitution.
- **`/setup`**: setup wizard for guild defaults.
- **`/accountinfo`**: website account lookup (email or Discord user).
- **`/stats`**, **`/search`**, **`/backup`**, **`/export`**, **`/email-routing`**.
- **Context menus**: create ticket from message; view user tickets.
- **Optional REST API** under `/api` when the relevant API key env vars are set (see `.env.example`).
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ BROCCOLINI BOT │
├─────────────────────────────────────────────────────────────────┤
│ Gmail (inbox) ──► gmail-poll.js ──► Discord ticket channels │
│ │ ▲ │
│ ▼ │ │
│ services/gmail.js ◄── handlers/messages.js │
│ services/tickets.js handlers/buttons.js │
│ services/channelQueue.js │
│ services/staffNotifications.js │
│ │ │
│ ▼ │
│ MongoDB (Mongoose) ◄── models.js │
│ │
│ Express: GET / → "Active" ; optional /api → routes/ │
└─────────────────────────────────────────────────────────────────┘
```
**Typical email ticket lifecycle**
1. New unread mail → poll creates Discord channel + `Ticket` document.
2. Staff reply in channel → message handler sends Gmail reply (email tickets only).
3. Close confirmed → transcript, optional closure email, channel delete; DB marked closed.
---
## Prerequisites
| Requirement | Notes |
|-------------|--------|
| **Node.js** | **18+**; Docker image uses **20** (`Dockerfile`). |
| **npm** | `npm install` locally; `npm ci --omit=dev` in Docker. |
| **MongoDB** | Self-hosted; `MONGODB_URI` required at startup. |
| **Discord application** | Bot token, application ID; intents: **Message Content**, **Server Members**; also Guilds + Guild Messages. |
| **Google Cloud** | Gmail API enabled; OAuth2 client ID/secret + refresh token for the support mailbox. |
For an architectural overview, see [HOWITWORKS.md](HOWITWORKS.md). For agent/contributor conventions, see [CLAUDE.md](CLAUDE.md).
---
## Quick start
```bash
git clone <your-repo-url>
git clone <repo-url>
cd broccolini-bot
npm install
cp .env.example .env
# fill DISCORD_TOKEN, DISCORD_APPLICATION_ID, DISCORD_GUILD_ID, TICKET_CATEGORY_ID,
# ROLE_ID_TO_PING, MONGODB_URI, GOOGLE_CLIENT_ID/SECRET, REFRESH_TOKEN, MY_EMAIL
npm start
```
1. Fill **Discord** (`DISCORD_TOKEN` or `DISCORD_BOT_TOKEN`, `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID`, categories, `ROLE_ID_TO_PING`, transcript/log channels).
2. Fill **MongoDB** (`MONGODB_URI`).
3. Fill **Google** OAuth (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `REFRESH_TOKEN`, `MY_EMAIL`) — use `node get-refresh-token.js` once if needed.
4. Run `npm start`.
5. In Discord, use **`/setup`** or verify categories and roles manually.
Need a Gmail refresh token? `node get-refresh-token.js` (redirect URI `http://localhost:3000/oauth2callback`). Probe Mongo with `npm run test-mongodb`.
Restart after **any** `.env` change. After changing **slash command definitions**, restart so **`registerCommands()`** re-registers with Discord.
Restart the bot after any `.env` change. Restart also re-registers slash commands.
---
## Deploy (Docker)
## Installation
```bash
docker compose up --build -d
docker logs broccolini --tail 50 -f
```
Same as quick start. Optional:
- **Test env:** copy `.env.test.example``.env.test`. On **Unix shells**: `npm run start:test` (sets `ENV_FILE`). On **Windows PowerShell**: `$env:ENV_FILE='.env.test'; node broccolini-discord.js` (or set `ENV_FILE` in the environment your process manager uses). **`npm run test-mongodb:test`** has the same `ENV_FILE` pattern.
- **1Password CLI:** `npm run start:1p` / `start:test:1p` inject secrets (see [docs/setup/1PASSWORD.md](docs/setup/1PASSWORD.md)).
**Do not commit** `.env` or `.env.test`. AI/agents should not edit production `.env` without explicit approval; see [ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md).
---
Host port `8892` → container `5000` (`DISCORD_ONLY_PORT`).
## Configuration
Configuration is **environment variables** only, loaded in [`config.js`](config.js) as `CONFIG`. Names below match `.env.example` unless noted.
### Discord (core)
| Variable | Required | Description |
|----------|----------|-------------|
| `DISCORD_TOKEN` | Yes* | Bot token (*or `DISCORD_BOT_TOKEN`, first non-empty wins after trim). |
| `DISCORD_APPLICATION_ID` | Yes | Used as `CLIENT_ID` for REST command registration. |
| `DISCORD_GUILD_ID` | Yes | Guild where slash commands are registered. |
| `TICKET_CATEGORY_ID` | Yes | Default category for **email** tickets (startup validates this). |
| `DISCORD_TICKET_CATEGORY_ID` | No | Category for **Discord** panel/context tickets (falls back to `TICKET_CATEGORY_ID`). |
| `EMAIL_THREAD_CHANNEL_ID` / `DISCORD_THREAD_CHANNEL_ID` | No | Parent **text** channels for thread-style tickets, when used. |
| `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated extra categories when the main is full (50 channels). |
| `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS` | No | Same for Discord ticket category. |
| `ROLE_ID_TO_PING` | Yes | Support role pinged on new tickets; alias `ROLE_TO_PING_ID` in code. |
| `ADDITIONAL_STAFF_ROLES` | No | Extra role IDs treated as staff for commands. |
| `BLACKLISTED_ROLES` | No | Roles blocked from opening tickets. |
| `TRANSCRIPT_CHANNEL_ID` | No | Where transcripts are posted on close. |
| `LOGGING_CHANNEL_ID` | No | Ticket lifecycle logs. |
| `DEBUGGING_CHANNEL_ID` | No | Optional error/debug forwarding. |
| `BACKUP_EXPORT_CHANNEL_ID` | No | Target for `/backup` and `/export`. |
| `ACCOUNT_INFO_CHANNEL_ID` | No | Account info flows. |
### Escalation categories
| Variable | Description |
|----------|-------------|
| `EMAIL_ESCALATED_CATEGORY_ID` | Legacy fallback; alias `ESCALATED_CATEGORY_ID`. |
| `DISCORD_ESCALATED_CATEGORY_ID` | Discord fallback tier-2style bucket. |
| `DISCORD_ESCALATED2_CHANNEL_ID` | Tier **2** placement for Discord tickets (or + fallback category). |
| `EMAIL_ESCALATED2_CHANNEL_ID` | Tier **2** for email tickets (env name says CHANNEL for legacy reasons). |
| `DISCORD_ESCALATED3_CHANNEL_ID` | Tier **3** Discord. |
| `EMAIL_ESCALATED3_CHANNEL_ID` | Tier **3** email. |
Slash `/escalate` and buttons require the appropriate tier IDs for **non-thread** channels (threads skip category moves).
### Staff notifications, claimer display, admin
| Variable | Description |
|----------|-------------|
| `STAFF_NOTIFICATION_CATEGORY_ID` | Category where **`/notification add`** creates per-staffer notification channels. |
| `STAFF_EMOJIS` | Comma-separated `discordUserId:emoji` pairs; used in **channel name** when a ticket is claimed. |
| `CLAIMER_EMOJI_FALLBACK` | Emoji if the claimer has no `STAFF_EMOJIS` entry. |
| `ADMIN_ID` | Discord user ID allowed to use **`/staffnotification`** (override cooldown for another member). |
| `UNCLAIMED_REMINDER_THRESHOLDS` | Comma-separated **hours** (e.g. `1,2,4`); drives unclaimed ticket alerts into notification channels. |
### Logging & observability
| Variable | Description |
|----------|-------------|
| `GMAIL_LOG_CHANNEL_ID` | Channel for Gmail poll activity logs. |
| `AUTOMATION_LOG_CHANNEL_ID` | Channel for auto-close/auto-unclaim/reminder logs. |
| `RENAME_LOG_CHANNEL_ID` | Channel for channel rename queue logs. |
| `SECURITY_LOG_CHANNEL_ID` | Channel for security/audit logs. |
| `SYSTEM_LOG_CHANNEL_ID` | Channel for bot lifecycle logs (startup, shutdown, DB events). |
### Pattern detection & surge/chat alerts
Core behaviour is configured via `.env.example`; high level:
- **Pattern detection** (`patternStore.js`, `patternChecker.js`):
- `USER_PATTERNS_CHANNEL_ID`, `GAME_PATTERNS_CHANNEL_ID`, `TAG_PATTERNS_CHANNEL_ID`, `ESCALATION_PATTERNS_CHANNEL_ID`, `STAFF_PATTERNS_CHANNEL_ID`, `COMBINED_PATTERNS_CHANNEL_ID` select where pattern embeds are posted.
- Threshold envs like `PATTERN_USER_TICKET_THRESHOLD`, `PATTERN_GAME_TICKET_THRESHOLD`, `PATTERN_UNCLAIMED_HOURS`, `PATTERN_ESCALATION_THRESHOLD`, `PATTERN_RAPID_CLOSE_SECONDS` tune when alerts fire.
- Windows (`today`, `week`, `month`) reset automatically via scheduled timers in `patternStore.scheduleResets()`.
- **Surge detection** (`surgeChecker.js`):
- `ALL_STAFF_CHANNEL_ID` is the primary surge-alert channel; `SURGE_ROLE_ID` is pinged when set.
- `SURGE_TICKET_COUNT` / `SURGE_TICKET_WINDOW_MINUTES`, `SURGE_GAME_TICKET_COUNT` / `SURGE_GAME_TICKET_WINDOW_MINUTES`, `SURGE_STALE_*`, `SURGE_NEEDS_RESPONSE_*`, `SURGE_UNCLAIMED_*`, `SURGE_TIER3_UNCLAIMED_MINUTES`, `SURGE_COOLDOWN_MINUTES` control volume/backlog alerts.
- `SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD`, `SURGE_NO_STAFF_COOLDOWN_MINUTES`, `STAFF_IDS`, and `STAFF_DND_COUNTS_AS_AVAILABLE` drive “no staff available” alerts (presence-based with message activity fallback).
- **Chat monitoring** (`chatAlertChecker.js`):
- `CHAT_ALERT_CHANNEL_IDS` lists channels to monitor.
- `CHAT_ALERT_MESSAGE_COUNT`, `CHAT_ALERT_HOURS_WITHOUT_RESPONSE`, `CHAT_ALERT_COOLDOWN_MINUTES` configure when to send chat-attention alerts to `ALL_STAFF_CHAT_ALERT_CHANNEL_ID`.
### Google / Gmail
| Variable | Required | Description |
|----------|----------|-------------|
| `GOOGLE_CLIENT_ID` | Yes | OAuth2 client ID. |
| `GOOGLE_CLIENT_SECRET` | Yes | OAuth2 secret. |
| `REFRESH_TOKEN` | Yes | Long-lived refresh for the inbox account. |
| `MY_EMAIL` | Yes | Canonical support address (lowercased in config). |
### MongoDB
| Variable | Required |
|----------|----------|
| `MONGODB_URI` | Yes |
Test: `npm run test-mongodb` (optionally with `ENV_FILE` / `.env.test` as above).
### Server & optional HTTP API
| Variable | Default | Description |
|----------|---------|-------------|
| `DISCORD_ONLY_PORT` | `5000` | Express listen port (`CONFIG.PORT`). |
| `HEALTHCHECK_HOST` | *(all interfaces)* | e.g. `127.0.0.1` for local-only bind. |
Additional variables for mounting **`/api`** (API key, CORS, etc.) are listed in `.env.example` if you use that integration.
### Messaging & branding
See `.env.example` for defaults: `ESCALATION_MESSAGE` (`{support_name}`), `TICKET_WELCOME_MESSAGE`, `TICKET_CLAIMED_MESSAGE` / `TICKET_UNCLAIMED_MESSAGE` (`{staff_mention}`, `{staff_name}`), `DISCORD_CLOSE_MESSAGE`, `DISCORD_TRANSCRIPT_MESSAGE` (`{channel_name}`, `{email}`, `{date_opened}`, `{date_closed}`), `EMAIL_SIGNATURE` (`\n``<br>`), embed color hex vars, button labels/emojis, `SUPPORT_NAME`, `LOGO_URL`.
### Automation & limits
- **Auto-close:** `AUTO_CLOSE_ENABLED`, `AUTO_CLOSE_AFTER_HOURS`, `AUTO_CLOSE_MESSAGE`.
- **Reminders:** `REMINDER_ENABLED`, `REMINDER_AFTER_HOURS`, `REMINDER_MESSAGE` (`{ping}`, `{hours}`).
- **Limits:** `GLOBAL_TICKET_LIMIT`, `TICKET_LIMIT_PER_CATEGORY`, `RATE_LIMIT_TICKETS_PER_USER`, `RATE_LIMIT_WINDOW_MINUTES`.
- **Claim:** `ALLOW_CLAIM_OVERWRITE`, `AUTO_UNCLAIM_*`, `CLAIM_TIMEOUT_ENABLED`, `CLAIM_TIMEOUT_HOURS`.
- **Priority:** `PRIORITY_ENABLED`, `DEFAULT_PRIORITY`, `PRIORITY_*_EMOJI`.
### Game list
`GAME_LIST=comma,separated,names` — used for detection/normalization in email handling (plus aliases in `config.js`).
### Thread-style tickets (legacy)
`USE_THREADS`, `THREAD_PARENT_CHANNEL` (see `.env.example`) — optional legacy paths; primary behavior is also governed by **`GuildSettings.emailRouting`** (`/email-routing`: `thread` | `category`).
---
## Staff notification channels & reply alerts
When `STAFF_NOTIFICATION_CATEGORY_ID` is set:
1. **`/notification add`** (with a target member) creates a channel under that category and saves `userId``channelId` + default **cooldown** in **`StaffNotification`**.
2. **`/notification set hours:`** (16) updates the cooldown between **reply alerts** for that users claimed tickets (same ticket keys off `gmailThreadId`).
3. **`/staffnotification`** (admin, `ADMIN_ID`) sets cooldown for **another** staff member.
4. On **messageCreate**, if the ticket has a `claimerId` and the author is **not** detected as having `ROLE_ID_TO_PING`, **`notifyStaffOfReply`** may post in the claimers notification channel (respecting cooldown).
5. **Every 30 minutes**, **`notifyAllStaffUnclaimed`** evaluates open tickets with `claimedBy: null` against `UNCLAIMED_REMINDER_THRESHOLDS` and posts to all configured notification channels (tracks sent thresholds on the ticket in `unclaimedReminderssent`).
**`/notifydm`** toggles **`StaffSettings.notifyDm`** for the invoking user; when enabled, claimers can also receive a **DM** on customer reply (in addition to any notification channel).
---
## Broccolini settings page
The repo includes an optional **Broccolini settings** web UI under `settings-site/` for configuring the bot without editing `.env` directly.
- Runs as a small Express app (`settings-site/server.js`) on `SETTINGS_PORT` and talks to the bots internal API on `INTERNAL_API_PORT` using `INTERNAL_API_SECRET`.
- Serves a password-protected dashboard (`SETTINGS_ADMIN_PASSWORD`) where you can adjust Discord channels, categories, Gmail credentials, ticket behavior, surge alerts, pattern thresholds, appearance, staff options, and advanced settings.
- Changes are sent to the bots internal `/internal/config` endpoints and can be saved as pending, applied immediately, or saved and paired with an immediate or scheduled restart.
To use it, run `node settings-site/server.js` alongside the bot (or via Docker), set the `SETTINGS_*` and `INTERNAL_API_*` variables as in `.env.example`, and put it behind HTTPS with your preferred reverse proxy.
---
## Running the bot (test and Docker)
```bash
npm start
# or
node broccolini-discord.js
```
**Test / alternate env file:** see [Installation](#installation) for `ENV_FILE` on Windows vs Unix.
```bash
npm run test-mongodb
```
**Docker** (see [`Dockerfile`](Dockerfile)):
```bash
docker build -t broccolini-bot .
docker run --env-file .env -p 5000:5000 broccolini-bot
```
Ensure `MONGODB_URI` and Discord token are available inside the container. A sample [`docker-compose.yml`](docker-compose.yml) exists—adjust **ports** and **env_file** for your host (do not copy production-specific bind addresses into new deployments without review).
---
## Discord commands
Most commands require **staff** (`ROLE_ID_TO_PING` or `ADDITIONAL_STAFF_ROLES`). **`/help`** is available more broadly per registration.
| Command | Description |
|---------|-------------|
| **`/setup`** | Guild setup wizard (panel, role, category, transcript channel, etc.). |
| **`/panel`** | Post a ticket **Open** button in a channel (optional `type`: thread / category / both; custom title/description). |
| **`/email-routing`** | Choose whether **new email** tickets create **threads** vs **category channels** (`GuildSettings` in DB). |
| **`/escalate`** | **Required:** `level` (Tier 2 or Tier 3), `action` (`unclaim` clears `claimedBy` + `claimerId` after escalation, `keep` preserves claim). |
| **`/deescalate`** | Step down one tier (tier 3 → 2 → normal). |
| **`/notifydm`** | `setting`: `on` / `off` — DM when a **non-staff** user replies in a ticket you claimed. |
| **`/notification`** | Subcommands: `set` (cooldown hours), `add` (create notification channel for a member). |
| **`/staffnotification`** | Admin only (`ADMIN_ID`); override another members notification cooldown. |
| **`/add`**, **`/remove`** | Add/remove user overwrites on the current ticket channel. |
| **`/transfer`** | Set `claimedBy` to another staff member (must have staff role). |
| **`/move`** | Move channel to another **category** (direct `setParent`). |
| **`/force-close`** | Close without button confirmation (still archives transcript best-effort). |
| **`/topic`** | Set Discord channel topic. |
| **`/priority`** | `low` / `normal` / `medium` / `high`. |
| **`/tag`** | Set ticket tag category from dropdown (choices from `TICKET_TAGS` in `config.js`). |
| **`/response`** | Subcommands: `send`, `create`, `edit`, `delete`, `list` (saved responses). |
| **`/accountinfo`** | Subcommands: `email`, `discord`. |
| **`/search`** | Search tickets by email, subject, or number. |
| **`/stats`** | Bot analytics snapshot. |
| **`/backup`**, **`/export`** | Post exports to `BACKUP_EXPORT_CHANNEL_ID`. |
| **`/help`** | In-bot command summary embed. |
**Context menus**
- **Create Ticket From Message** — opens a ticket prefilled from a message.
- **View User Tickets** — lists recent tickets for a user (by sender tag match).
---
## Ticket UI (buttons & modals)
- **Open ticket** (panel): modal fields are **account email**, **game**, **description**.
- In ticket channels: **Close**, **Claim/Unclaim**, **Escalate** (tier choice), **De-escalate** as built in [`utils/ticketComponents.js`](utils/ticketComponents.js) / [`handlers/buttons.js`](handlers/buttons.js).
- **Email routing** and **tag delete** confirmations use additional button custom IDs.
---
## Tag & response system
### `/tag`
Sets `ticketTag` from the fixed list in `config.js` (`TICKET_TAGS`). Channel naming may incorporate tag/priority emojis via ticket naming logic.
### `/response`
Templates support variables such as `{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{staff.mention}`, `{server.name}`, `{date}`, `{time}` (see [`utils.js`](utils.js) / handler docs).
---
## Panel system
1. Run **`/panel`** targeting a channel (and optional style: thread-only, category-only, or both buttons).
2. User clicks **Open ticket** → modal → bot creates thread or channel per configuration.
3. Welcome embeds + action row are posted; `Ticket` stores `discordThreadId`, `ticketNumber`, etc.
---
## Channel renames & moves (rate limits)
Discord allows **two renames per 10 minutes** per channel. The bot serializes renames/moves through [`services/channelQueue.js`](services/channelQueue.js) (`p-queue`). If rename is blocked, staff see a message with a **relative time** to retry.
---
## Project structure
```
broccolini-bot/
├── broccolini-discord.js # Entry: Discord client, Express, Gmail poll interval, jobs
├── config.js # Env → CONFIG (game lists, TICKET_TAGS, STAFF_EMOJIS map, …)
├── db-connection.js # Mongo connect + require models
├── models.js # Mongoose schemas (Ticket, Tag, StaffSettings, StaffNotification, …)
├── utils.js # Email/game helpers, template variables
├── utils/ticketComponents.js # Action row builders
├── gmail-poll.js # Ingest Gmail → Discord ticket creation
├── get-refresh-token.js # One-shot OAuth refresh token helper
├── commands/register.js # Slash + context menu registration (discord.js v14)
├── handlers/
│ ├── buttons.js # Claim/close/modals/escalate buttons, ticket create modal
│ ├── commands.js # Slash handlers, runEscalation/runDeescalation
│ ├── messages.js # Staff ↔ Gmail relay; notifydm; notification alerts
│ ├── accountinfo.js
│ ├── analytics.js
│ └── setup.js
├── services/
│ ├── gmail.js
│ ├── tickets.js # Auto-close, reminders, auto-unclaim, naming helpers
│ ├── channelQueue.js # enqueueRename / enqueueMove
│ ├── staffChannel.js # Legacy mirror helpers (unused in current claim flow)
│ ├── staffNotifications.js # Reply alerts + unclaimed reminders
│ ├── staffSettings.js # notifydm prefs
│ ├── guildSettings.js
│ └── debugLog.js
├── routes/ # Optional Express `/api` routes
├── api/ # Bot client accessor for HTTP layer
├── scripts/ # Maintenance / one-off utilities
├── docs/ # Deeper guides (setup, security, MongoDB, API notes)
├── Dockerfile
├── docker-compose.yml
├── package.json
└── .env.example / .env.test.example
```
---
## Database collections
| Model / collection | Role |
|--------------------|------|
| **Ticket** | Gmail thread id, Discord channel/thread id, status, priority, claim (`claimedBy` display label, `claimerId`), legacy `staffChannelId`, escalation tier, `welcomeMessageId`, `ticketTag`, `unclaimedReminderssent`, etc. |
| **TicketCounter** | Per-sender local counters (legacy paths). |
| **Transcript** | Links closed tickets to transcript message IDs. |
| **Tag** | Saved response name + content. |
| **GuildSettings** | e.g. `emailRouting`: `thread` \| `category`. |
| **StaffSettings** | Per-user `notifyDm` (+ `guildId`, `updatedAt`). |
| **StaffNotification** | Per-user `channelId`, `cooldownHours` for reply/unclaimed alerts. |
| **CloseRequest** | Pending close workflow if used. |
| **User**, **Host**, **DashboardMetrics**, **ErrorLog** | Shared / website-era schemas in the same `models.js` file. |
---
## HTTP: healthcheck & optional API
- **`GET /`** → plain text **`Active`** (intended for load balancers / Docker `HEALTHCHECK`).
- **`/api/*`** is registered **only after** the bot is `ready` and the optional HTTP API is enabled via env (see `.env.example`). JSON body parsing is enabled; auth uses a Bearer token from configuration. Route definitions live under `routes/` in this repo.
---
## Gmail OAuth refresh token
```bash
node get-refresh-token.js
```
Requires `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in `.env`, and redirect URI **`http://localhost:3000/oauth2callback`** registered on the Google OAuth client. Paste the printed refresh token into `.env` as `REFRESH_TOKEN`.
---
## Documentation in `docs/`
Index: **[docs/README.md](docs/README.md)**. Highlights:
| Doc | Topic |
|-----|--------|
| [ENV_AND_SECURITY.md](docs/setup/ENV_AND_SECURITY.md) | Secrets, test env, agent rules |
| [MONGODB_SETUP.md](docs/setup/MONGODB_SETUP.md) | Database |
| [QUICKSTART.md](docs/setup/QUICKSTART.md) | First-time orientation |
| [PROJECT_STRUCTURE.md](docs/setup/PROJECT_STRUCTURE.md) | Layout (may overlap this README) |
| [1PASSWORD.md](docs/setup/1PASSWORD.md) | 1Password CLI for `npm run start:1p` |
---
## Development & CI
This repo includes [`.gitlab-ci.yml`](.gitlab-ci.yml) with GitLab **SAST** and **secret detection** templates. Adjust or extend stages in GitLab as needed for your fork.
---
All config is environment variables loaded by `config.js` into `CONFIG`. The full list — with descriptions and defaults — lives in [`.env.example`](.env.example). Highlights:
| Variable | Notes |
|----------|-------|
| `DISCORD_TOKEN` / `DISCORD_BOT_TOKEN` | Bot token. First non-empty after trim wins. |
| `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID` | Required for slash command registration. |
| `TICKET_CATEGORY_ID` | Default category for email tickets. Validated at startup. |
| `DISCORD_TICKET_CATEGORY_ID` | Category for Discord panel/context tickets (falls back to `TICKET_CATEGORY_ID`). |
| `ROLE_ID_TO_PING` | Support role pinged on new tickets. |
| `MONGODB_URI` | Mongo connection string. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `REFRESH_TOKEN` / `MY_EMAIL` | Gmail OAuth + canonical inbox address. |
| `RENAMER_BOT` | Optional secondary token used for channel renames. |
| `INTERNAL_API_SECRET` / `INTERNAL_API_PORT` | Enable the internal config API used by the settings UI. |
## Slash commands
| Command | Purpose |
|---------|---------|
| `/escalate`, `/deescalate` | Move ticket between tier 2/3 categories. |
| `/add`, `/remove` | Add/remove user from current ticket channel. |
| `/transfer` | Hand the claim to another staff member. |
| `/move` | Reparent the channel to another category. |
| `/force-close`, `/cancel-close`, `/closetimer` | Force-close flow with cancellable countdown. |
| `/topic` | Set channel topic. |
| `/response` | Saved reply templates (`send`, `create`, `edit`, `delete`, `list`). |
| `/panel` | Post an "Open ticket" panel button (thread / category / both). |
| `/notifydm` | Toggle DM alerts when a customer replies in your claimed ticket. |
| `/signature` | Personal email signature (valediction, display name, tagline). |
| `/staffthread` | Toggle / configure staff-only threads on tickets. |
| `/pinmessages` | Auto-pin welcome / escalation messages. |
| `/gmailpoll` | Set the Gmail poll interval at runtime. |
| `/help` | In-bot summary. |
Plus context menus: **Create Ticket From Message**, **View User Tickets**.
## Settings UI (optional)
`settings-site/` is a separate Express app that talks to the bot's internal config API over the `broccoli-net` Docker network using `INTERNAL_API_SECRET`. It is **not** part of this bot's process. See [`settings-site/CLAUDE.md`](settings-site/CLAUDE.md).
## Troubleshooting
| Symptom | Checks |
|---------|--------|
| **Commands missing** | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; **restart** bot; Discord can take time to sync. |
| **Gmail not ingesting** | `REFRESH_TOKEN`, API enablement, inbox auth; regenerate token if revoked. |
| **MongoDB errors** | `MONGODB_URI`, `npm run test-mongodb`. |
| **Channels not creating** | Bot **Manage Channels** in ticket categories; category not full (50) unless overflow set. |
| **Modal / button no response** | Intents + permissions; bot online; check `DEBUGGING_CHANNEL_ID` / console. |
| **Renames “too quickly”** | Discord rename cooldown; wait for channel queue / timestamp in bot message. |
| **Test script env on Windows** | `npm run start:test` sets `ENV_FILE` Unix-style; use PowerShell `ENV_FILE` + `node` if the script fails. |
---
## References
| Technology | Link |
|------------|------|
| discord.js v14 | [discord.js guide](https://discordjs.guide/) |
| Google APIs (Gmail) | [googleapis Node](https://github.com/googleapis/google-api-nodejs-client) |
| Mongoose | [mongoosejs.com](https://mongoosejs.com/) |
| Express | [expressjs.com](https://expressjs.com/) |
---
| Symptom | Check |
|---------|-------|
| Slash commands missing | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; restart; Discord can take a minute to sync. |
| Gmail not ingesting | `REFRESH_TOKEN` valid? Auth failure halts polling — re-auth and restart. |
| Mongo errors at startup | `MONGODB_URI` reachable? `npm run test-mongodb` to confirm. |
| Channel rename "too quickly" | Discord limit is 2 renames/10 min per channel — the queue serializes; wait it out. |
| Modal/button no response | Bot online + intents enabled; check `DEBUGGING_CHANNEL_ID` / container logs. |
## License

View File

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

View File

@@ -2,7 +2,7 @@
* Entry point initializes the Discord bot, wires event handlers,
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck.
*/
const { Client, GatewayIntentBits, Partials } = require('discord.js');
const { Client, GatewayIntentBits, Partials, MessageFlags } = require('discord.js');
const express = require('express');
const { connectMongoDB, closeMongoDB } = require('./db-connection');
const { CONFIG } = require('./config');
@@ -11,22 +11,15 @@ const { mongoose } = require('./db-connection');
// Handlers
const { handleButton, handleTicketModal } = require('./handlers/buttons');
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup');
const { requireStaffRole } = require('./handlers/commands/helpers');
const { handleDiscordReply } = require('./handlers/messages');
// Services & jobs
const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
const { registerCommands } = require('./commands/register');
// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup.
const { setBot } = require('./api/botClient');
const { poll } = require('./gmail-poll');
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog');
// Re-export utilities for any external consumers
const { sendGmailReply } = require('./services/gmail');
const { getNextTicketNumber } = require('./services/tickets');
const { getCleanBody, detectGame, stripEmailQuotes, stripMobileFooter, htmlToTextWithBlocks } = require('./utils');
const { setClient: setDebugClient, logError } = require('./services/debugLog');
let gmailPollInterval = null;
// Track all background setInterval handles so shutdown can clear them.
@@ -94,7 +87,7 @@ const client = new Client({
// --- EVENT: interactionCreate ---
async function safeReplyError(interaction) {
const payload = { content: 'Something went wrong.', ephemeral: true };
const payload = { content: 'Something went wrong.', flags: MessageFlags.Ephemeral };
if (interaction.deferred || interaction.replied) {
await interaction.followUp(payload).catch(() => {});
} else {
@@ -113,31 +106,14 @@ async function runHandler(name, interaction, fn) {
}
client.on('interactionCreate', async interaction => {
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
try {
const handled = await handleSetupButton(interaction);
if (handled) return;
} catch (err) {
console.error('Setup button error:', err);
logError('handleSetupButton', err, null, client).catch(() => {});
await interaction.reply({
content: `Setup error: ${err.message}. Try \`/setup\` again.`,
ephemeral: true
}).catch(() => {});
return;
}
}
if (interaction.isButton()) {
return runHandler('handleButton', interaction, () => handleButton(interaction));
}
if (interaction.isModalSubmit() && interaction.customId.startsWith(SETUP_MODAL_PREFIX)) {
const handled = await runHandler('handleSetupModal', interaction, () => handleSetupModal(interaction));
if (handled) return;
}
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) {
// Staff-only: /signature shows this modal, which is gated; double-gate the
// submit path in case an attacker crafts the submission directly.
if (await requireStaffRole(interaction)) return;
// Handle signature modal submit
try {
const valediction = interaction.fields.getTextInputValue('valediction');
@@ -160,13 +136,13 @@ client.on('interactionCreate', async interaction => {
await interaction.reply({
content: 'Signature settings saved successfully!',
ephemeral: true
flags: MessageFlags.Ephemeral
});
} catch (err) {
console.error('Signature modal submit error:', err);
await interaction.reply({
content: 'Failed to save signature settings.',
ephemeral: true
flags: MessageFlags.Ephemeral
});
}
return;
@@ -176,11 +152,6 @@ client.on('interactionCreate', async interaction => {
return runHandler('handleTicketModal', interaction, () => handleTicketModal(interaction));
}
if (interaction.customId?.startsWith(SETUP_SELECT_PREFIX) && (interaction.isRoleSelectMenu() || interaction.isChannelSelectMenu())) {
const handled = await runHandler('handleSetupSelect', interaction, () => handleSetupSelect(interaction));
if (handled) return;
}
if (interaction.isChatInputCommand()) {
return runHandler('handleCommand', interaction, () => handleCommand(interaction));
}
@@ -198,6 +169,14 @@ client.on('messageCreate', async msg => {
await handleDiscordReply(msg);
});
// HTTP server handles + readiness flag. Assigned inside the ready callback
// (httpServer, appReady) and the INTERNAL_API_SECRET branch below
// (internalServer); declared here so they're visible to the ready callback,
// the express middleware below, and the shutdown handler at the bottom.
let httpServer = null;
let internalServer = null;
let appReady = false;
client.once('ready', async () => {
if (!process.env.MONGODB_URI) {
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
@@ -205,7 +184,6 @@ client.once('ready', async () => {
}
await connectMongoDB(process.env.MONGODB_URI);
setDebugClient(client);
setBot(client);
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
@@ -255,18 +233,6 @@ client.once('ready', async () => {
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
console.log('✓ Discord bot ready. Tag:', client.user.tag);
logSystem('Bot online', [
{ name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' },
{ name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` },
{ name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' },
{ name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' },
{ name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
{ name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
{ name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' },
{ name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' },
{ name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' }
]).catch(() => {});
});
client.login(CONFIG.DISCORD_TOKEN);
@@ -274,7 +240,7 @@ client.login(CONFIG.DISCORD_TOKEN);
const app = express();
app.use(express.json());
// Reject API traffic with 503 until ready event has fired and routes are mounted.
let appReady = false;
// (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.' });
@@ -289,8 +255,6 @@ const internalApi = require('./routes/internalApi');
const internalApp = express();
internalApp.use('/internal', internalApi);
let httpServer = null;
let internalServer = null;
if (CONFIG.INTERNAL_API_SECRET) {
// Must bind all-interfaces inside the bot container: the settings-site is a
// separate container on broccoli-net and reaches this API over the docker
@@ -310,10 +274,7 @@ let shuttingDown = false;
async function handleShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
await Promise.race([
logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]),
new Promise(r => setTimeout(r, 2000))
]);
console.log(`Bot shutting down (${signal})`);
for (const handle of activeIntervals) {
try { clearInterval(handle); } catch (_) {}
}
@@ -339,13 +300,5 @@ module.exports = {
client,
setGmailPollInterval,
clearGmailPollInterval,
trackTimeout,
sendGmailReply,
sendTicketClosedEmail,
getNextTicketNumber,
getCleanBody,
detectGame,
stripEmailQuotes,
stripMobileFooter,
htmlToTextWithBlocks
trackTimeout
};

View File

@@ -205,13 +205,6 @@ async function registerCommands() {
InteractionContextType.PrivateChannel
]),
new SlashCommandBuilder()
.setName('setup')
.setDescription('Run the panel setup wizard (name, support role, category, transcript channel, panel channel)')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels),
new SlashCommandBuilder()
.setName('panel')
.setDescription('Create a ticket panel for users to open Discord tickets')
@@ -253,13 +246,6 @@ async function registerCommands() {
.setRequired(false)
),
new SlashCommandBuilder()
.setName('email-routing')
.setDescription('Switch where new email tickets are created: threads or category channels')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
new SlashCommandBuilder()
.setName('notifydm')
.setDescription('Toggle DM notifications when your ticket receives a customer reply.')
@@ -371,8 +357,6 @@ async function registerCommands() {
.setDescription('Poll interval')
.setRequired(true)
.addChoices(
{ name: '5s', value: '5' },
{ name: '10s', value: '10' },
{ name: '30s', value: '30' },
{ name: '45s', value: '45' },
{ name: '1m', value: '60' },

View File

@@ -1,25 +1,9 @@
/**
* Broccolini Bot configuration and game lists.
* Load dotenv so env is available when this module is required first.
* dotenv-expand resolves ${NGROK_URL} etc. in .env.
*
* Never commit .env; agents must not modify .env without explicit user confirmation.
*/
const path = require('path');
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const parsed = dotenv.config({ debug: process.env.NODE_ENV === 'development' });
dotenvExpand.expand(parsed);
// Also load repo-root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env)
const rootEnv = path.resolve(process.cwd(), '..', '.env');
const rootParsed = dotenv.config({ path: rootEnv });
if (!rootParsed.error && rootParsed.parsed) {
for (const [k, v] of Object.entries(rootParsed.parsed)) {
if (v != null && String(v).trim() !== '') process.env[k] = v;
}
dotenvExpand.expand(rootParsed);
}
require('dotenv').config({ debug: process.env.NODE_ENV === 'development' });
function toInt(v, fallback) {
const n = parseInt(v, 10);
@@ -31,34 +15,23 @@ const CONFIG = {
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets',
TICKET_T2_CATEGORY_NAME: process.env.TICKET_T2_CATEGORY_NAME || 'Tier 2 Escalated Tickets',
TICKET_T3_CATEGORY_NAME: process.env.TICKET_T3_CATEGORY_NAME || 'Tier 3 Escalated Tickets',
EMAIL_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean),
DISCORD_TICKET_CATEGORY_ID: process.env.DISCORD_TICKET_CATEGORY_ID || process.env.TICKET_CATEGORY_ID,
DISCORD_TICKET_OVERFLOW_CATEGORY_IDS: (process.env.DISCORD_TICKET_OVERFLOW_CATEGORY_IDS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean),
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_CHAN: process.env.TRANSCRIPT_CHANNEL_ID,
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
LOGO_URL: process.env.LOGO_URL,
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
STAFF_EMOJIS: Object.fromEntries((process.env.STAFF_EMOJIS||'').split(',').map(s=>s.trim()).filter(Boolean).map(p=>{const i=p.indexOf(':');return i===-1?null:[p.slice(0,i).trim(),p.slice(i+1).trim()];}).filter(Boolean)),
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
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
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
GAME_LIST: process.env.GAME_LIST || '',
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null,
// 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,
DISCORD_ESCALATED2_CHANNEL_ID: process.env.DISCORD_ESCALATED2_CHANNEL_ID || null,
@@ -75,9 +48,7 @@ const CONFIG = {
DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.',
AUTO_CLOSE_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true',
AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72),
AUTO_CLOSE_MESSAGE: process.env.AUTO_CLOSE_MESSAGE || 'This ticket has been automatically closed due to inactivity.',
GLOBAL_TICKET_LIMIT: toInt(process.env.GLOBAL_TICKET_LIMIT, 5),
TICKET_LIMIT_PER_CATEGORY: toInt(process.env.TICKET_LIMIT_PER_CATEGORY, 3),
RATE_LIMIT_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0),
RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60),
BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
@@ -96,8 +67,6 @@ const CONFIG = {
AUTO_UNCLAIM_ENABLED: process.env.AUTO_UNCLAIM_ENABLED === 'true',
AUTO_UNCLAIM_AFTER_HOURS: toInt(process.env.AUTO_UNCLAIM_AFTER_HOURS, 24),
ALLOW_CLAIM_OVERWRITE: process.env.ALLOW_CLAIM_OVERWRITE === 'true',
USE_THREADS: process.env.USE_THREADS === 'true',
THREAD_PARENT_CHANNEL: process.env.THREAD_PARENT_CHANNEL || process.env.EMAIL_THREAD_CHANNEL_ID || null,
BUTTON_LABEL_CLOSE: process.env.BUTTON_LABEL_CLOSE || 'Close Ticket',
BUTTON_LABEL_CLAIM: process.env.BUTTON_LABEL_CLAIM || 'Claim',
BUTTON_LABEL_UNCLAIM: process.env.BUTTON_LABEL_UNCLAIM || 'Unclaim',
@@ -105,18 +74,13 @@ const CONFIG = {
BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌',
BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓',
EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00),
EMBED_COLOR_CLOSED: toInt(process.env.EMBED_COLOR_CLOSED, 0xFF0000),
EMBED_COLOR_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
ADMIN_ID: process.env.ADMIN_ID || null,
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_LOG_CHANNEL_ID: process.env.GMAIL_LOG_CHANNEL_ID || null,
AUTOMATION_LOG_CHANNEL_ID: process.env.AUTOMATION_LOG_CHANNEL_ID || null,
RENAME_LOG_CHANNEL_ID: process.env.RENAME_LOG_CHANNEL_ID || null,
SECURITY_LOG_CHANNEL_ID: process.env.SECURITY_LOG_CHANNEL_ID || null,
SYSTEM_LOG_CHANNEL_ID: process.env.SYSTEM_LOG_CHANNEL_ID || null,
STAFF_THREAD_ENABLED: process.env.STAFF_THREAD_ENABLED === 'true',
STAFF_THREAD_NAME: process.env.STAFF_THREAD_NAME || 'Staff Discussion',
STAFF_THREAD_AUTO_ADD_ROLE: process.env.STAFF_THREAD_AUTO_ADD_ROLE === 'true',
@@ -148,42 +112,8 @@ const GAME_ALIASES = {
CS2: 'Counter-Strike 2'
};
const GAME_NAME_TO_KEY = {
'Project Zomboid': 'project_zomboid',
'Satisfactory': 'satisfactory',
'Palworld': 'palworld',
'Minecraft': 'minecraft',
'Valheim': 'valheim',
'Enshrouded': 'enshrouded',
'7 Days to Die': '7_days_to_die',
'Hytale': 'hytale',
'ICARUS': 'icarus',
'Abiotic Factor': 'abiotic_factor',
'ARK: Survival Evolved': 'ark_survival_evolved',
'Conan Exiles': 'conan_exiles',
'Core Keeper': 'core_keeper',
'Counter-Strike 2': 'counter_strike_2',
'DayZ': 'dayz',
'ECO': 'eco',
'Factorio': 'factorio',
'FiveM': 'fivem',
'The Front': 'the_front',
"Garry's Mod": 'garrys_mod',
'Necesse': 'necesse',
'Rust': 'rust',
'Sons of the Forest': 'sons_of_the_forest',
'Soulmask': 'soulmask',
'Star Rupture': 'star_rupture',
'Terraria': 'terraria',
'VEIN': 'vein',
'Vintage Story': 'vintage_story',
'Voyagers of Nera': 'voyagers_of_nera',
'V Rising': 'v_rising'
};
module.exports = {
CONFIG,
GAME_NAMES,
GAME_ALIASES,
GAME_NAME_TO_KEY
GAME_ALIASES
};

View File

@@ -22,23 +22,16 @@ async function connectMongoDB(uri, options = {}) {
await mongoose.connect(uri, defaultOptions);
console.log('✓ Connected to MongoDB');
// Handle connection events
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
const { logSystem: ls } = require('./services/debugLog');
ls('MongoDB error', [{ name: 'Error', value: err.message }], null, 0xFF0000).catch(() => {});
});
mongoose.connection.on('disconnected', () => {
console.warn('MongoDB disconnected. Attempting to reconnect...');
const { logSystem: ls } = require('./services/debugLog');
ls('MongoDB disconnected', [], null, 0xFFFF00).catch(() => {});
});
mongoose.connection.on('reconnected', () => {
console.log('✓ MongoDB reconnected');
const { logSystem: ls } = require('./services/debugLog');
ls('MongoDB reconnected', []).catch(() => {});
});
} catch (err) {

0
git
View File

View File

@@ -1,29 +1,30 @@
/**
* Gmail polling fetches unread emails and creates/updates Discord ticket channels.
*
* `poll()` is the orchestrator: list → locate guild → for each message,
* parse → look up existing → branch (append-followup vs create-ticket) → mark read.
* Each step delegates to a single-responsibility helper below.
*/
const {
ChannelType,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder
EmbedBuilder,
PermissionFlagsBits
} = require('discord.js');
const { mongoose, withRetry } = require('./db-connection');
const { CONFIG, GAME_NAME_TO_KEY } = require('./config');
const { CONFIG } = require('./config');
const {
getCleanBody,
extractRawEmail,
stripEmailQuotes,
stripMobileFooter,
detectGame,
enforceEmbedLimit,
sanitizeEmbedText
} = require('./utils');
const { getGmailClient } = require('./services/gmail');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, createEmailTicketAsThread, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { getEmailRouting } = require('./services/guildSettings');
const { logError, logGmail, logAutomation } = require('./services/debugLog');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents');
const Ticket = mongoose.model('Ticket');
const Transcript = mongoose.model('Transcript');
@@ -31,7 +32,6 @@ const Transcript = mongoose.model('Transcript');
let isPolling = false;
let authErrorNotified = false;
let pollSuspended = false;
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
function setPollSuspended(val) {
pollSuspended = !!val;
@@ -39,6 +39,228 @@ function setPollSuspended(val) {
}
function isPollSuspended() { return pollSuspended; }
// ============================================================
// Helpers (extracted from the original 309-line poll()).
// ============================================================
/**
* Pick the guild for this poll iteration. Honors DISCORD_GUILD_ID when set,
* otherwise falls back to the first guild in the cache. Returns null with a
* warning if no usable guild is available; caller should bail.
*/
function locateGuild(client) {
if (CONFIG.DISCORD_GUILD_ID) {
const g = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!g) {
console.warn('Configured guild not found for DISCORD_GUILD_ID:', CONFIG.DISCORD_GUILD_ID);
}
return g || null;
}
const g = client.guilds.cache.first();
if (!g) {
console.warn('No guilds in cache; skipping poll iteration.');
}
return g || null;
}
/**
* Parse a Gmail message payload into normalized fields.
*
* Body cleanup runs twice with different rules:
* - firstBody: aggressive — strip quotes if it looks like a reply, strip
* mobile footers, collapse newlines. Used as the first message in a new
* ticket channel where we want only the user's actual message.
* - followupBody: defensive — strip quotes but fall back to raw text if
* stripping leaves nothing. Used for follow-up posts on an existing thread.
*/
function parseGmailMessage(email) {
const headers = email.data.payload.headers;
const from = headers.find(h => h.name === 'From')?.value || '';
const isSelf = from.toLowerCase().includes(CONFIG.MY_EMAIL);
const subject = headers.find(h => h.name === 'Subject')?.value || 'New Ticket';
const rawBody = getCleanBody(email.data.payload);
const senderEmail = extractRawEmail(from).toLowerCase();
const senderName = (from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
?.replace(/"/g, '')
.trim() || 'Unknown';
const hasReplyHeaderFrom = /(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
const looksLikeReply = /\nOn .+wrote:/i.test(rawBody) || hasReplyHeaderFrom;
let firstBody = rawBody.replace(/\r\n/g, '\n');
if (looksLikeReply) firstBody = stripEmailQuotes(firstBody);
firstBody = stripMobileFooter(firstBody);
firstBody = firstBody.replace(/^\s*\n+/g, '');
firstBody = firstBody.replace(/\n{3,}/g, '\n\n');
firstBody = firstBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText);
if (!followupBody.trim()) followupBody = rawText;
followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody);
followupBody = followupBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
return {
isSelf,
threadId: email.data.threadId,
from,
subject,
rawBody,
senderEmail,
senderName,
firstBody,
followupBody
};
}
/**
* Resolve the parent category and create a fresh ticket channel under it.
* Returns { channel, parentCategoryId } on success, or null on failure (caller
* should mark the message read and skip — same behavior as the original inline path).
*/
async function findOrCreateTicketChannel(guild, parsed, number) {
const creatorNickname = getSenderLocal(parsed.senderEmail);
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
let parentCategoryId;
try {
parentCategoryId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('Channel create error (payload):', {
message: err.message,
code: err.code,
rawError: err.rawError
});
return null;
}
try {
const channel = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentCategoryId,
// Email tickets have no Discord creator — the customer is reachable
// only by email. So the only per-channel allow is the staff role; we
// still explicitly deny @everyone in case the category permissions
// are ever misconfigured to grant View Channel server-wide.
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 };
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
return null;
}
}
/**
* Post links + attachments for prior transcripts of a reopened thread.
* Best-effort: any failure is logged and swallowed so the new ticket flow
* continues unaffected.
*/
async function linkPreviousTranscripts(ticketChan, threadId, client) {
try {
const transcriptRows = await Transcript.find({ gmailThreadId: threadId })
.sort({ createdAt: 1 })
.select('transcriptMessageId')
.lean();
if (transcriptRows.length === 0) return;
const transcriptChan = await client.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (!transcriptChan) return;
await enqueueSend(
ticketChan,
`This email thread has ${transcriptRows.length} previous transcript(s):`
);
for (const row of transcriptRows) {
const transcriptMsg = await transcriptChan.messages
.fetch(row.transcriptMessageId)
.catch(() => null);
if (!transcriptMsg) continue;
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
const originalAttachment = transcriptMsg.attachments.first();
if (originalAttachment) {
await enqueueSend(ticketChan, {
content: 'Transcript file:',
files: [originalAttachment.url]
});
}
}
} catch (err) {
console.error('Error linking previous transcripts:', err);
}
}
/** Drop UNREAD + INBOX labels from a Gmail message — equivalent to "archive + read". */
async function markGmailMessageRead(gmail, msgRef) {
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
}
/**
* If the error indicates a permanent OAuth-grant failure (invalid_grant /
* invalid_client), suspend polling, clear the recurring poll interval, log,
* and DM the admin once. Returns true iff polling was suspended (caller
* should not treat as a transient retry-on-next-tick error).
*
* Transient 401/403/429/5xx and network errors are NOT considered permanent —
* they fall through to the next interval naturally. The OAuth code lives on
* `err.response.data.error`, not the message string.
*/
function oauthSuspendIfPermanent(err, client) {
const oauthError = err && err.response && err.response.data && err.response.data.error;
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
if (!isPermanentAuth) return false;
pollSuspended = true;
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
console.error('[gmail-poll]', suspendMsg);
logError('Gmail OAuth', { message: suspendMsg, stack: err.stack || err.message || String(err) }, null, client).catch(() => {});
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
if (CONFIG.ADMIN_ID && !authErrorNotified) {
authErrorNotified = true;
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
}
return true;
}
// ============================================================
// Orchestrator
// ============================================================
/**
* Poll Gmail for unread primary-inbox messages and route them to Discord.
* @param {import('discord.js').Client} client
@@ -47,13 +269,6 @@ async function poll(client) {
if (isPolling || pollSuspended) return;
isPolling = true;
try {
pollCount++;
if (pollCount % 10 === 0) {
if (totalProcessed > 0 || totalSkipped > 0 || totalErrors > 0) {
logAutomation('Gmail poll summary', null, `polls: ${pollCount}, processed: ${totalProcessed}, skipped: ${totalSkipped}, errors: ${totalErrors}`).catch(() => {});
}
pollCount = 0; totalProcessed = 0; totalSkipped = 0; totalErrors = 0;
}
console.log('Running poll()...');
try {
const gmail = getGmailClient();
@@ -63,89 +278,19 @@ async function poll(client) {
});
if (!list.data.messages) return;
let guild;
if (CONFIG.DISCORD_GUILD_ID) {
guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!guild) {
console.warn(
'Configured guild not found for DISCORD_GUILD_ID:',
CONFIG.DISCORD_GUILD_ID
);
return;
}
} else {
guild = client.guilds.cache.first();
if (!guild) {
console.warn('No guilds in cache; skipping poll iteration.');
return;
}
}
const guild = locateGuild(client);
if (!guild) return;
for (const msgRef of list.data.messages) {
const email = await gmail.users.messages.get({
userId: 'me',
id: msgRef.id
});
const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
const parsed = parseGmailMessage(email);
const from =
email.data.payload.headers.find(h => h.name === 'From')
?.value || '';
if (from.toLowerCase().includes(CONFIG.MY_EMAIL)) {
totalSkipped++;
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
if (parsed.isSelf) {
await markGmailMessageRead(gmail, msgRef);
continue;
}
const subject =
email.data.payload.headers.find(h => h.name === 'Subject')
?.value || 'New Ticket';
const rawBody = getCleanBody(email.data.payload);
const sEmail = extractRawEmail(from).toLowerCase();
const sName =
(from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
?.replace(/"/g, '')
.trim() || 'Unknown';
const hasReplyHeaderFrom =
/(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody);
const looksLikeReply =
/\nOn .+wrote:/i.test(rawBody) ||
hasReplyHeaderFrom;
let firstBodyText = rawBody.replace(/\r\n/g, '\n');
if (looksLikeReply) {
firstBodyText = stripEmailQuotes(firstBodyText);
}
firstBodyText = stripMobileFooter(firstBodyText);
firstBodyText = firstBodyText.replace(/^\s*\n+/g, '');
firstBodyText = firstBodyText.replace(/\n{3,}/g, '\n\n');
firstBodyText = firstBodyText
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const firstBody = firstBodyText;
const rawText = rawBody.replace(/\r\n/g, '\n');
let followupBody = stripEmailQuotes(rawText);
if (!followupBody.trim()) {
followupBody = rawText;
}
followupBody = followupBody.replace(/^\s*\n*/, '\n');
followupBody = followupBody.replace(/\n{3,}/g, '\n\n');
followupBody = stripMobileFooter(followupBody);
followupBody = followupBody
.replace(/Get Outlook for [^\n]+/gi, '')
.replace(/<\s*$/gm, '')
.trim();
const existing = await Ticket.findOne({ gmailThreadId: email.data.threadId })
const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
.select('gmailThreadId discordThreadId status')
.lean();
@@ -154,112 +299,48 @@ async function poll(client) {
let isReopened = false;
if (existing && existing.discordThreadId) {
ticketChan = await guild.channels
.fetch(existing.discordThreadId)
.catch(() => null);
ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null);
} else if (existing && existing.status === 'closed') {
isReopened = true;
}
if (ticketChan) {
const truncatedFollowup = followupBody.slice(0, 1800);
// Append follow-up to existing channel.
const truncatedFollowup = parsed.followupBody.slice(0, 1800);
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
await enqueueSend(
ticketChan,
{
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: ['roles'] }
}
);
});
} else {
// Check ticket limits before creating
const limitCheck = await checkTicketLimits(sEmail);
// Create a new ticket channel.
const limitCheck = await checkTicketLimits(parsed.senderEmail);
if (!limitCheck.ok) {
totalSkipped++;
console.log(`Ticket limit reached for ${sEmail}: ${limitCheck.reason}`);
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
console.log(`Ticket limit reached for ${parsed.senderEmail}: ${limitCheck.reason}`);
await markGmailMessageRead(gmail, msgRef);
continue;
}
const { number } = await getNextTicketNumber(sEmail);
const creatorNickname = getSenderLocal(sEmail);
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`);
try {
const routing = await getEmailRouting(guild.id);
if (routing === 'thread' && CONFIG.EMAIL_THREAD_CHANNEL_ID) {
ticketChan = await createEmailTicketAsThread(guild, number, chanName);
parentCategoryIdForTicket = ticketChan.parent?.parentId ?? null;
} else {
const parentId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
parentCategoryIdForTicket = parentId;
try {
ticketChan = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentId
});
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
throw createErr;
}
}
} catch (err) {
console.error('Channel create error (payload):', {
message: err.message,
code: err.code,
rawError: err.rawError
});
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
const { number } = await getNextTicketNumber(parsed.senderEmail);
const created = await findOrCreateTicketChannel(guild, parsed, number);
if (!created) {
await markGmailMessageRead(gmail, msgRef);
continue;
}
ticketChan = created.channel;
parentCategoryIdForTicket = created.parentCategoryId;
const detectedGame = detectGame(subject, rawBody);
const gameKey =
detectedGame && detectedGame !== 'Not Mentioned'
? GAME_NAME_TO_KEY[detectedGame] || null
: null;
const buttons = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('close_ticket')
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId('claim_ticket')
.setLabel(CONFIG.BUTTON_LABEL_CLAIM)
.setEmoji(CONFIG.BUTTON_EMOJI_CLAIM)
.setStyle(ButtonStyle.Secondary)
);
const detectedGame = detectGame(parsed.subject, parsed.rawBody);
const buttons = getTicketActionRow({ escalationTier: 0 });
const ticketInfoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields(
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false },
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false },
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderName)}\n\`\`\``, inline: false },
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(parsed.senderEmail)}\n\`\`\``, inline: false },
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false },
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false }
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
);
enforceEmbedLimit([ticketInfoEmbed]);
const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed],
@@ -275,66 +356,29 @@ async function poll(client) {
await pinMessage(welcomeMsg, client).catch(() => {});
}
// On reopen, link previous transcripts
if (isReopened) {
try {
const transcriptRows = await Transcript.find({ gmailThreadId: email.data.threadId })
.sort({ createdAt: 1 })
.select('transcriptMessageId')
.lean();
if (transcriptRows.length > 0) {
const transcriptChan = await client.channels
.fetch(CONFIG.TRANSCRIPT_CHAN)
.catch(() => null);
if (transcriptChan) {
await enqueueSend(
ticketChan,
`This email thread has ${transcriptRows.length} previous transcript(s):`
);
for (const row of transcriptRows) {
const transcriptMsg = await transcriptChan.messages
.fetch(row.transcriptMessageId)
.catch(() => null);
if (!transcriptMsg) continue;
await enqueueSend(ticketChan, `Transcript: ${transcriptMsg.url}`);
const originalAttachment = transcriptMsg.attachments.first();
if (originalAttachment) {
await enqueueSend(ticketChan, {
content: 'Transcript file:',
files: [originalAttachment.url]
});
}
}
}
}
} catch (err) {
console.error('Error linking previous transcripts:', err);
}
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
}
const truncated = firstBody.slice(0, 1900);
// Email body is attacker-controlled — no mentions may fire from its content.
await enqueueSend(ticketChan, { content: `**Message:**\n${truncated}`, allowedMentions: { parse: [] } });
const truncated = parsed.firstBody.slice(0, 1900);
await enqueueSend(ticketChan, {
content: `**Message:**\n${truncated}`,
allowedMentions: { parse: [] }
});
// Welcome message skipped for email tickets the email body speaks for itself.
// Panel-created (Discord) tickets still send the welcome message in handlers/buttons.js.
const now = new Date();
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
await withRetry(() => Ticket.findOneAndUpdate(
{ gmailThreadId: email.data.threadId },
{ gmailThreadId: parsed.threadId },
{
$set: {
discordThreadId: ticketChan.id,
senderEmail: sEmail,
subject,
senderEmail: parsed.senderEmail,
subject: parsed.subject,
createdAt: now,
status: 'open',
ticketNumber: number,
@@ -345,40 +389,14 @@ async function poll(client) {
},
{ upsert: true, new: true }
));
totalProcessed++;
logGmail(subject, sEmail, number, detectedGame).catch(() => {});
}
console.log('Archiving/reading Gmail message', msgRef.id);
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
await markGmailMessageRead(gmail, msgRef);
}
authErrorNotified = false;
} catch (e) {
// Only treat Google-reported permanent-grant failures as reasons to suspend
// the loop. Transient 401/403/429/5xx/network errors fall through to the
// next interval tick naturally. The OAuth error codes come back on the
// response body, not the message string.
const oauthError = e && e.response && e.response.data && e.response.data.error;
const isPermanentAuth = oauthError === 'invalid_grant' || oauthError === 'invalid_client';
if (isPermanentAuth) {
pollSuspended = true;
const suspendMsg = `Gmail OAuth ${oauthError}. Polling SUSPENDED — will not retry automatically. Re-authenticate and POST /internal/gmail/reload to resume.`;
console.error('[gmail-poll]', suspendMsg);
logError('Gmail OAuth', { message: suspendMsg, stack: e.stack || e.message || String(e) }, null, client).catch(() => {});
try { require('./broccolini-discord').clearGmailPollInterval?.(); } catch (_) {}
if (CONFIG.ADMIN_ID && !authErrorNotified) {
authErrorNotified = true;
client.users.fetch(CONFIG.ADMIN_ID).then(u => u.send(suspendMsg)).catch(() => {});
}
}
totalErrors++;
oauthSuspendIfPermanent(e, client);
console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

128
handlers/commands/close.js Normal file
View File

@@ -0,0 +1,128 @@
/**
* Force-close flow: /force-close, /cancel-close, /closetimer, plus the
* countdown-elapses finalize step and transcript renderer that the
* countdown's setTimeout calls back into.
*
* Note: the button-driven close path lives in handlers/buttons.js
* (handleCloseButton / handleConfirmCloseRequest / runFinalClose).
* This module covers the slash-command-driven path only.
*/
const { AttachmentBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { enqueueSend } = require('../../services/channelQueue');
const { logTicketEvent } = require('../../services/debugLog');
const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers');
const Ticket = mongoose.model('Ticket');
async function handleCloseTimer(interaction) {
const seconds = parseInt(interaction.options.getString('seconds'), 10);
CONFIG.FORCE_CLOSE_TIMER = seconds;
logTicketEvent('Close timer updated', [
{ name: 'Duration', value: `${seconds}s` },
{ name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
}
async function handleCancelClose(interaction) {
const pending = pendingCloses.get(interaction.channel.id);
if (!pending) {
return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral });
}
clearTimeout(pending.timeout);
logTicketEvent('Force-close cancelled', [
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
{ name: 'Cancelled by', value: interaction.user.tag },
{ name: 'Original setter', value: pending.username || 'Unknown' }
], interaction).catch(() => {});
pendingCloses.delete(interaction.channel.id);
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
}
async function handleForceClose(interaction) {
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
}
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
const channelRef = interaction.channel;
const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
}
/** Performs the actual force-close work after the countdown elapses. */
async function finalizeForceClose(channelRef, clientRef) {
pendingCloses.delete(channelRef.id);
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
try {
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
// a stale message ID pointing into the now-deleted channel.
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
);
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
console.error('Transcript error (force-close):', tErr)
);
setTimeout(() => {
channelRef.delete('Ticket force-closed').catch(e =>
console.error('Failed to delete channel:', e)
);
}, 5000);
} catch (err) {
console.error('Force close error:', err);
}
}
/** Render and post a closing transcript for a ticket. */
async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 });
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), {
name: `transcript-${channelRef.name}.txt`
});
const transcriptChan = await clientRef.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (!transcriptChan) return;
const fmt = (d) => 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'
});
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 };

View File

@@ -0,0 +1,168 @@
/**
* Right-click "Apps" menu commands:
* - "Create Ticket From Message" — turn a Discord message into a ticket.
* - "View User Tickets" — show last 10 tickets for the targeted user.
*/
const {
ChannelType,
EmbedBuilder,
MessageFlags,
PermissionFlagsBits
} = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { getPriorityEmoji } = require('../../utils');
const { checkTicketCreationRateLimit, getOrCreateTicketCategory } = require('../../services/tickets');
const { getTicketActionRow } = require('../../utils/ticketComponents');
const { enqueueSend } = require('../../services/channelQueue');
const { logError } = require('../../services/debugLog');
const Ticket = mongoose.model('Ticket');
async function handleCreateTicketFromMessage(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const rateLimit = checkTicketCreationRateLimit(interaction.user.id);
if (!rateLimit.allowed) {
const mins = Math.ceil((rateLimit.retryAfterMs || 0) / 60000);
return interaction.editReply(`You can only create ${CONFIG.RATE_LIMIT_TICKETS_PER_USER} ticket(s) per ${CONFIG.RATE_LIMIT_WINDOW_MINUTES} minutes. Try again in ${mins} minute(s).`);
}
try {
const message = interaction.targetMessage;
const subject = `Message from ${message.author.tag}`;
const description = message.content || 'No content';
const guild = interaction.guild;
const lastTicket = await Ticket.findOne().sort({ ticketNumber: -1 }).select('ticketNumber').lean();
const ticketNumber = (lastTicket?.ticketNumber || 0) + 1;
let parentCategoryIdForTicket;
try {
parentCategoryIdForTicket = await getOrCreateTicketCategory(
guild,
CONFIG.DISCORD_TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
} catch (err) {
console.error('getOrCreateTicketCategory (context menu ticket):', err);
return interaction.editReply('❌ Discord ticket category could not be resolved. Contact an administrator.');
}
let channel;
try {
channel = await guild.channels.create({
name: `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentCategoryIdForTicket,
permissionOverwrites: [
{ 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) {
console.error('guild.channels.create (context menu ticket):', err);
return interaction.editReply('❌ Failed to create ticket channel. Contact an administrator.');
}
const gmailThreadId = `discord-msg-${Date.now()}-${message.id}`;
const now = new Date();
await Ticket.create({
gmailThreadId,
discordThreadId: channel.id,
senderEmail: message.author.tag,
subject,
createdAt: now,
status: 'open',
ticketNumber,
priority: 'normal',
lastActivity: now,
creatorId: message.author.id,
parentCategoryId: parentCategoryIdForTicket
});
const welcomeEmbed = new EmbedBuilder()
.setDescription(CONFIG.TICKET_WELCOME_MESSAGE)
.setColor(CONFIG.EMBED_COLOR_INFO);
const infoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields(
{ name: 'From message', value: `[Jump to message](${message.url})` },
{ name: 'Creator', value: message.author.toString(), inline: true },
{ name: 'Created by Staff', value: interaction.user.toString(), inline: true },
{ name: 'Content', value: description.slice(0, 1000) || 'No content', inline: false }
);
const row = getTicketActionRow({ escalationTier: 0 });
try {
const welcomeMsg = await enqueueSend(channel, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\nHey There ${message.author} 🥦`,
embeds: [welcomeEmbed, infoEmbed],
components: [row]
});
await Ticket.updateOne(
{ discordThreadId: channel.id },
{ $set: { welcomeMessageId: welcomeMsg.id } }
);
} catch (err) {
console.error('welcomeMessageId-save', err);
}
await interaction.editReply(`✅ Ticket created: ${channel}`);
} catch (err) {
logError('create-ticket-from-message', err, interaction).catch(() => {});
await interaction.editReply('❌ Failed to create ticket from message.');
}
}
async function handleViewUserTickets(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
try {
const targetUser = interaction.targetUser;
const tickets = await Ticket.find({ senderEmail: targetUser.tag })
.sort({ createdAt: -1 })
.limit(10)
.lean();
if (!tickets || tickets.length === 0) {
return interaction.editReply(`📋 No tickets found for ${targetUser.tag}`);
}
const embed = new EmbedBuilder()
.setTitle(`📋 Tickets for ${targetUser.tag}`)
.setDescription(`Found ${tickets.length} ticket(s)`)
.setColor(CONFIG.EMBED_COLOR_INFO);
for (const ticket of tickets.slice(0, 5)) {
const priorityEmoji = getPriorityEmoji(ticket.priority || 'normal');
const statusEmoji = ticket.status === 'open' ? '🟢' : '🔴';
embed.addFields({
name: `${priorityEmoji} Ticket #${ticket.ticketNumber} ${statusEmoji}`,
value: `**Subject:** ${ticket.subject || 'No subject'}\n**Status:** ${ticket.status}\n**Claimed:** ${ticket.claimedBy || 'Unclaimed'}`,
inline: false
});
}
if (tickets.length > 5) {
embed.setFooter({ text: `Showing 5 of ${tickets.length} tickets` });
}
await interaction.editReply({ embeds: [embed] });
} catch (err) {
logError('view-user-tickets', err, interaction).catch(() => {});
await interaction.editReply('❌ Failed to fetch user tickets.');
}
}
module.exports = { handleCreateTicketFromMessage, handleViewUserTickets };

View File

@@ -0,0 +1,213 @@
/**
* Escalation flows.
*
* runEscalation / runDeescalation are exported for handlers/buttons.js
* (the tier-pick buttons share this code path). handleEscalate /
* handleDeescalate are the slash-command entry points.
*/
const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { sendTicketNotificationEmail } = require('../../services/gmail');
const { getTicketActionRow } = require('../../utils/ticketComponents');
const { enqueueRename, enqueueMove, enqueueSend } = require('../../services/channelQueue');
const { pinMessage } = require('../../services/pinMessage');
const { logError } = require('../../services/debugLog');
const { findTicketForChannel, runDeferred } = require('../sharedHelpers');
const { fetchLoggingChannel } = require('./helpers');
const Ticket = mongoose.model('Ticket');
/**
* Run escalation to a target tier (1 = tier 2, 2 = tier 3). Caller must
* validate ticket and currentTier < nextTier, and have already deferred.
*/
async function runEscalation(interaction, ticket, nextTier) {
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (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
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: true, escalationTier: nextTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = true;
ticket.escalationTier = nextTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const newName = makeTicketName('escalated', ticket, creatorNickname);
enqueueRename(interaction.channel, newName).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread() && categoryId) {
await enqueueMove(interaction.channel, categoryId);
}
const pendingEmbed = new EmbedBuilder()
.setDescription('Ticket will be escalated in a few seconds.')
.setColor(CONFIG.EMBED_COLOR_INFO);
await interaction.editReply({ embeds: [pendingEmbed] });
const creatorId = isDiscordTicket
? (ticket.gmailThreadId.split('-').pop() || '').trim()
: null;
const creatorMention = creatorId ? `<@${creatorId}>` : '';
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'a senior team member';
const heyLine = creatorMention ? `Hey There ${creatorMention} 🥦` : 'Hey There 🥦';
// Creator + role pings are intentional; still block @everyone/@here if somehow interpolated.
await enqueueSend(interaction.channel, {
content: `${heyLine}\n**Getting the senior ${roleMention} for you.**`,
allowedMentions: { parse: ['users', 'roles'] }
});
const escalationBody = CONFIG.ESCALATION_MESSAGE
.replace(/\\n/g, '\n')
.replace(/\{support_name\}/g, CONFIG.SUPPORT_NAME);
const escalatedEmbed = new EmbedBuilder()
.setTitle(`🚨 Escalated to ${nextTier === 1 ? 'Tier 2' : 'Tier 3'} Support`)
.setDescription(escalationBody)
.setColor(CONFIG.EMBED_COLOR_ESCALATED)
.setFooter({ text: `Escalated by ${interaction.member?.displayName || interaction.user.username}` });
const updatedTicketForRow = { ...ticket, escalationTier: nextTier, escalated: true };
const escalationRow = getTicketActionRow(updatedTicketForRow);
const escalationMsg = await enqueueSend(interaction.channel, {
content: null,
embeds: [escalatedEmbed],
components: [escalationRow]
});
if (CONFIG.PIN_ESCALATION_MESSAGE_ENABLED && escalationMsg) {
await pinMessage(escalationMsg, interaction.client).catch(() => {});
}
if (!isDiscordTicket && ticket.gmailThreadId) {
try {
const escalatorName = interaction.member?.displayName || interaction.user.username;
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
const emailBody = `${escalatorName} escalated this ticket to ${tierLabel}.`;
await sendTicketNotificationEmail(ticket, null, emailBody, interaction.user.id);
} catch (emailErr) {
console.error('Escalation email failed (non-fatal):', emailErr.message);
}
}
if (nextTier === 2 && ticket.welcomeMessageId) {
try {
const welcomeMsg = await interaction.channel.messages.fetch(ticket.welcomeMessageId);
await welcomeMsg.edit({ components: [getTicketActionRow(updatedTicketForRow)] });
} catch (e) {
console.error('Failed to update welcome message after escalate:', e.message);
}
}
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
const tierLabel = nextTier === 1 ? 'tier 2' : 'tier 3';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} escalated to ${tierLabel} by ${interaction.user.tag}.`
);
}
}
/** Run deescalation one step. Caller must validate ticket and currentTier >= 1. */
async function runDeescalation(interaction, ticket) {
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const newTier = currentTier - 1;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { escalated: newTier > 0, escalationTier: newTier, claimedBy: null, claimerId: null } }
);
ticket.escalated = newTier > 0;
ticket.escalationTier = newTier;
ticket.claimedBy = null;
const creatorNickname = await resolveCreatorNickname(interaction.guild, ticket);
const state = newTier === 0 ? 'unclaimed' : 'escalated';
enqueueRename(interaction.channel, makeTicketName(state, ticket, creatorNickname)).catch(err => logError('rename', err).catch(() => {}));
if (!interaction.channel.isThread()) {
try {
if (newTier === 0) {
const homeCategory = isDiscordTicket ? CONFIG.DISCORD_TICKET_CATEGORY_ID : CONFIG.TICKET_CATEGORY_ID;
if (homeCategory) await enqueueMove(interaction.channel, homeCategory);
} else if (newTier === 1) {
const t2Category = isDiscordTicket
? CONFIG.DISCORD_ESCALATED2_CHANNEL_ID
: CONFIG.EMAIL_ESCALATED2_CHANNEL_ID;
if (t2Category) await enqueueMove(interaction.channel, t2Category);
}
} catch (e) {
console.error('Move error (deescalate):', e);
}
}
const tierLabel = newTier === 0 ? 'normal' : newTier === 1 ? 'tier 2' : 'tier 3';
const deescalateEmbed = new EmbedBuilder()
.setColor(0x00BFFF)
.setTitle(`✅ De-escalated to ${tierLabel} Support`)
.setFooter({ text: interaction.member?.displayName || interaction.user.username });
await interaction.editReply({ embeds: [deescalateEmbed] });
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
const ticketType = isDiscordTicket ? 'Discord' : 'Email';
await enqueueSend(logChan,
`${ticketType} ticket ${interaction.channel} deescalated to ${tierLabel} by ${interaction.user.tag}.`
);
}
}
async function handleEscalate(interaction) {
const level = interaction.options.getString('level');
const nextTier = level === '3' ? 2 : 1;
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier >= 2) {
return interaction.reply({ content: 'This ticket is already at tier 3 support.', flags: MessageFlags.Ephemeral });
}
if (nextTier <= currentTier) {
return interaction.reply({ content: 'Ticket is already at or past that tier.', flags: MessageFlags.Ephemeral });
}
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
const categoryId = nextTier === 1
? (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';
if (!categoryId && !interaction.channel.isThread()) {
return interaction.reply({
content: `${configKey} is not configured for ${isDiscordTicket ? 'Discord' : 'email'} tickets.`,
flags: MessageFlags.Ephemeral
});
}
await runDeferred(interaction, 'escalate', () =>
runEscalation(interaction, ticket, nextTier)
);
}
async function handleDeescalate(interaction) {
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
const currentTier = ticket.escalationTier ?? (ticket.escalated ? 1 : 0);
if (currentTier === 0) {
return interaction.reply({ content: 'This ticket is not escalated.', flags: MessageFlags.Ephemeral });
}
await runDeferred(interaction, 'de-escalate',
() => runDeescalation(interaction, ticket),
{ flags: MessageFlags.Ephemeral }
);
}
module.exports = { runEscalation, runDeescalation, handleEscalate, handleDeescalate };

View File

@@ -0,0 +1,33 @@
/**
* Cross-submodule helpers for handlers/commands/*.
*
* Lives at this level (not in index.js) so escalation.js, close.js, etc. can
* import without creating circular dependencies with index.js.
*/
const { MessageFlags } = require('discord.js');
const { CONFIG } = require('../../config');
const { isStaff } = require('../../utils');
/**
* Reply ephemeral and return true if the interaction is in a guild and the
* user is not staff (so the caller should bail).
*/
async function requireStaffRole(interaction) {
if (!interaction.guild) return false;
if (!CONFIG.ROLE_ID_TO_PING && (!CONFIG.ADDITIONAL_STAFF_ROLES || CONFIG.ADDITIONAL_STAFF_ROLES.length === 0)) return false;
if (isStaff(interaction.member)) return false;
const roleMention = CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'support';
await interaction.reply({
content: `This command is only available to the support team (${roleMention}).`,
flags: MessageFlags.Ephemeral
});
return true;
}
/** Fetch the configured logging channel, or null if unset/missing. */
async function fetchLoggingChannel(client) {
if (!CONFIG.LOGGING_CHANNEL_ID) return null;
return client.channels.fetch(CONFIG.LOGGING_CHANNEL_ID).catch(() => null);
}
module.exports = { requireStaffRole, fetchLoggingChannel };

346
handlers/commands/index.js Normal file
View File

@@ -0,0 +1,346 @@
/**
* Slash command, context menu, and autocomplete dispatcher.
*
* Submodules own command handlers by topic:
* helpers.js — requireStaffRole, fetchLoggingChannel
* escalation.js — runEscalation, runDeescalation, handleEscalate, handleDeescalate
* close.js — handleForceClose, handleCancelClose, handleCloseTimer (+ finalize/transcript)
* response.js — /response subcommands + handleAutocomplete
* panel.js — handlePanel, handleSignature
* contextMenu.js — handleCreateTicketFromMessage, handleViewUserTickets
*
* This file holds the dispatchers, the small "remainder" handlers
* (channel-mod, settings toggles, /help, /notifydm), and the public
* module.exports surface that handlers/buttons.js + broccolini-discord.js
* import from `require('./commands')`.
*/
const { EmbedBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { isStaff } = require('../../utils');
const { setNotifyDm } = require('../../services/staffSettings');
const { makeTicketName, resolveCreatorNickname } = require('../../services/tickets');
const { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend } = require('../../services/channelQueue');
const { logError, logTicketEvent } = require('../../services/debugLog');
const { findTicketForChannel } = require('../sharedHelpers');
const { requireStaffRole, fetchLoggingChannel } = require('./helpers');
const { runEscalation, runDeescalation, handleEscalate, handleDeescalate } = require('./escalation');
const { handleCloseTimer, handleCancelClose, handleForceClose } = require('./close');
const { handleResponse, handleAutocomplete } = require('./response');
const { handlePanel, handleSignature } = require('./panel');
const { handleCreateTicketFromMessage, handleViewUserTickets } = require('./contextMenu');
const Ticket = mongoose.model('Ticket');
// ============================================================
// Remainder handlers — small enough not to deserve their own module.
// ============================================================
async function handleNotifyDm(interaction) {
try {
const setting = interaction.options.getString('setting') === 'on';
await setNotifyDm(interaction.user.id, interaction.guildId, setting);
await interaction.reply({
content: `DM notifications ${setting ? 'enabled ✅' : 'disabled 🔕'}.`,
flags: MessageFlags.Ephemeral
});
} catch (err) {
console.error('notifydm error:', err);
await interaction.reply({ content: 'Failed to update notification setting.', flags: MessageFlags.Ephemeral }).catch(() => {});
}
}
async function handleAdd(interaction) {
const user = interaction.options.getUser('user');
const ticket = await findTicketForChannel(interaction);
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 {
await enqueueOverwrite(interaction.channel, user.id, {
ViewChannel: true,
SendMessages: true,
ReadMessageHistory: true
});
await interaction.editReply({ content: `Added ${user} to this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) {
console.error('Add user error:', err);
await interaction.editReply({ content: 'Failed to add user.' }).catch(() => {});
}
}
async function handleRemove(interaction) {
const user = interaction.options.getUser('user');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
// Defer up front — same reason as handleAdd.
await interaction.deferReply();
try {
await enqueueOverwrite(interaction.channel, user.id, null, 'delete');
await interaction.editReply({ content: `Removed ${user} from this ticket.`, allowedMentions: { parse: ['users'] } });
} catch (err) {
console.error('Remove user error:', err);
await interaction.editReply({ content: 'Failed to remove user.' }).catch(() => {});
}
}
async function handleTransfer(interaction) {
const member = interaction.options.getUser('member');
const reason = interaction.options.getString('reason') || 'No reason provided';
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
// Cache-first member resolution; falls back to a fetch if not in cache.
// 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);
// Reject self-transfers and bots; require the target to satisfy isStaff(),
// 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 {
const claimerLabel = guildMember.displayName || guildMember.user.username;
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $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.
await interaction.editReply({
content: `Ticket transferred to ${member} by ${interaction.user}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan, {
content: `Ticket ${interaction.channel} transferred from ${interaction.user.tag} to ${member.tag}.\nReason: ${reason}`,
allowedMentions: { parse: ['users'] }
});
}
} catch (err) {
console.error('Transfer error:', err);
await interaction.editReply({ content: 'Failed to transfer ticket.' }).catch(() => {});
}
}
async function handleMove(interaction) {
const category = interaction.options.getChannel('category');
const ticket = await findTicketForChannel(interaction);
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 {
await enqueueMove(interaction.channel, category.id);
await interaction.editReply(`Moved ticket to **${category.name}**.`);
const logChan = await fetchLoggingChannel(interaction.client);
if (logChan) {
await enqueueSend(logChan,
`Ticket ${interaction.channel} moved to category **${category.name}** by ${interaction.user.tag}`
);
}
} catch (err) {
console.error('Move error:', err);
await interaction.editReply({ content: 'Failed to move ticket.' }).catch(() => {});
}
}
async function handleTopic(interaction) {
const text = interaction.options.getString('text');
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
// Defer up front — enqueueTopic serializes behind any pending rename/move.
await interaction.deferReply();
try {
await enqueueTopic(interaction.channel, text);
await interaction.editReply('Topic updated successfully.');
} catch (err) {
console.error('Topic error:', err);
await interaction.editReply({ content: 'Failed to update topic.' }).catch(() => {});
}
}
async function handleStaffThread(interaction) {
const sub = interaction.options.getSubcommand();
if (sub === 'toggle') {
CONFIG.STAFF_THREAD_ENABLED = !CONFIG.STAFF_THREAD_ENABLED;
return interaction.reply({ content: `Staff threads are now **${CONFIG.STAFF_THREAD_ENABLED ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'name') {
const name = interaction.options.getString('thread_name').slice(0, 100);
CONFIG.STAFF_THREAD_NAME = name;
return interaction.reply({ content: `Staff thread name set to **${name}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'autorole') {
const enabled = interaction.options.getBoolean('enabled');
CONFIG.STAFF_THREAD_AUTO_ADD_ROLE = enabled;
return interaction.reply({ content: `Auto-add role to staff thread is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
}
async function handlePinMessages(interaction) {
const sub = interaction.options.getSubcommand();
const enabled = interaction.options.getBoolean('enabled');
if (sub === 'initial') {
CONFIG.PIN_INITIAL_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin initial message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'escalation') {
CONFIG.PIN_ESCALATION_MESSAGE_ENABLED = enabled;
return interaction.reply({ content: `Auto-pin escalation message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
if (sub === 'suppress') {
CONFIG.PIN_SUPPRESS_SYSTEM_MESSAGE = enabled;
return interaction.reply({ content: `Suppress pin system message is now **${enabled ? 'enabled' : 'disabled'}**.`, flags: MessageFlags.Ephemeral });
}
}
async function handleGmailPoll(interaction) {
const requested = parseInt(interaction.options.getString('interval'), 10);
// Defense-in-depth: the slash command's addChoices already floors at 30s, but
// clamp the resolved ms here too so any future caller (or skewed input) can't
// drop below 30s and trip Gmail's per-user quota under sustained load.
const ms = Math.max(30000, requested * 1000);
const seconds = ms / 1000;
// Lazy require — broccolini-discord re-exports this and we'd otherwise cycle.
const { setGmailPollInterval } = require('../../broccolini-discord');
setGmailPollInterval(ms);
logTicketEvent('Gmail poll interval updated', [
{ name: 'Interval', value: `${seconds}s` },
{ name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.reply({ content: `Gmail poll interval set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
}
async function handleHelp(interaction) {
const embed = new EmbedBuilder()
.setTitle('Ticket System - Commands')
.setColor(CONFIG.EMBED_COLOR_OPEN)
.addFields([
{
name: 'User Management',
value: '`/add @user` - Add user to ticket\n`/remove @user` - Remove user from ticket'
},
{
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'
},
{
name: 'Saved Responses',
value: '`/response send <name>` - Send saved response\n`/response create|edit|delete|list` - Manage saved responses'
},
{
name: 'Variables (for responses)',
value: '`{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`, `{staff.name}`, `{server.name}`, `{date}`, `{time}`'
},
{
name: 'Panel System',
value: '`/panel #channel` - Create a ticket panel for Discord-side tickets'
},
{
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)'
}
])
.setFooter({ text: 'Click buttons on ticket messages to claim/close' });
await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral });
}
// ============================================================
// Dispatch tables
// ============================================================
const COMMAND_HANDLERS = {
escalate: handleEscalate,
deescalate: handleDeescalate,
notifydm: handleNotifyDm,
add: handleAdd,
remove: handleRemove,
transfer: handleTransfer,
move: handleMove,
staffthread: handleStaffThread,
pinmessages: handlePinMessages,
gmailpoll: handleGmailPoll,
closetimer: handleCloseTimer,
'cancel-close': handleCancelClose,
'force-close': handleForceClose,
topic: handleTopic,
response: handleResponse,
signature: handleSignature,
help: handleHelp,
panel: handlePanel
};
const CONTEXT_MENU_HANDLERS = {
'Create Ticket From Message': handleCreateTicketFromMessage,
'View User Tickets': handleViewUserTickets
};
/**
* Slash-command dispatcher. Every command is staff-only — including /help,
* which previously bypassed the role check.
*/
async function handleCommand(interaction) {
if (await requireStaffRole(interaction)) return;
const handler = COMMAND_HANDLERS[interaction.commandName];
if (handler) await handler(interaction);
}
/** Context-menu dispatcher. All entries are staff-only. */
async function handleContextMenu(interaction) {
if (await requireStaffRole(interaction)) return;
const handler = CONTEXT_MENU_HANDLERS[interaction.commandName];
if (handler) await handler(interaction);
}
module.exports = {
handleCommand,
handleContextMenu,
handleAutocomplete,
runEscalation,
runDeescalation
};

133
handlers/commands/panel.js Normal file
View File

@@ -0,0 +1,133 @@
/**
* /panel — create a ticket-creation panel embed in a chosen channel.
* Also hosts /signature (modal for staff personal email signature) since
* both are user-facing UX-flow commands without their own dedicated module.
*/
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
MessageFlags,
ModalBuilder,
TextInputBuilder,
TextInputStyle
} = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { enqueueSend } = require('../../services/channelQueue');
const StaffSignature = mongoose.model('StaffSignature');
async function handlePanel(interaction) {
const channel = interaction.options.getChannel('channel');
const panelType = interaction.options.getString('type') || null; // 'thread' | 'category' | 'both' or null
const title = interaction.options.getString('title') || 'Indifferent Broccoli Tickets';
const description = interaction.options.getString('description') ||
'Need help? Click below to create a ticket. 🎟';
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(0x2ecc71)
.setThumbnail(CONFIG.LOGO_URL || null)
.setFooter({ text: 'Indifferent Broccoli Tickets' });
const row = buildPanelButtonRow(panelType);
try {
await enqueueSend(channel, { embeds: [embed], components: [row] });
await interaction.reply({ content: `Panel created in ${channel}!`, flags: MessageFlags.Ephemeral });
} catch (err) {
console.error('Panel creation error:', err);
await interaction.reply({ content: 'Failed to create panel.', flags: MessageFlags.Ephemeral });
}
}
function buildPanelButtonRow(panelType) {
if (panelType === 'both') {
return new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket (thread)')
.setStyle(ButtonStyle.Secondary)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket (channel)')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📁')
);
}
if (panelType === 'thread') {
return new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Secondary)
.setEmoji('🧵')
);
}
if (panelType === 'category') {
return new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Secondary)
.setEmoji('📁')
);
}
return new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Secondary)
.setEmoji('✅')
);
}
async function handleSignature(interaction) {
try {
const existingSignature = await StaffSignature.findOne({ userId: interaction.user.id }).lean();
const modal = new ModalBuilder()
.setCustomId(`signature_modal_${interaction.user.id}`)
.setTitle('Staff Signature Settings');
const valedictionInput = new TextInputBuilder()
.setCustomId('valediction')
.setLabel('Valediction (e.g. "Best regards", "Thanks")')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setValue(existingSignature?.valediction || '');
const displayNameInput = new TextInputBuilder()
.setCustomId('display_name')
.setLabel('Display Name (e.g. "Support Team")')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setValue(existingSignature?.displayName || '');
const taglineInput = new TextInputBuilder()
.setCustomId('tagline')
.setLabel('Tagline (e.g. "Technical Support Specialist")')
.setStyle(TextInputStyle.Short)
.setRequired(false)
.setValue(existingSignature?.tagline || '');
modal.addComponents(
new ActionRowBuilder().addComponents(valedictionInput),
new ActionRowBuilder().addComponents(displayNameInput),
new ActionRowBuilder().addComponents(taglineInput)
);
await interaction.showModal(modal);
} catch (err) {
console.error('Signature command error:', err);
if (!interaction.replied && !interaction.deferred) {
await interaction.reply({ content: 'Failed to open signature settings.', flags: MessageFlags.Ephemeral }).catch(() => {});
}
}
}
module.exports = { handlePanel, handleSignature };

View File

@@ -0,0 +1,165 @@
/**
* /response (saved tags) and its autocomplete.
*
* /response is itself a router over its subcommands:
* send / create / edit / delete / list
* The autocomplete handler also lives here since the only autocompleting
* slash command is /response.
*/
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
MessageFlags
} = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { replaceVariables } = require('../../utils');
const { logError } = require('../../services/debugLog');
const Tag = mongoose.model('Tag');
const Ticket = mongoose.model('Ticket');
async function handleResponse(interaction) {
const subcommand = interaction.options.getSubcommand();
const handler = RESPONSE_SUBCOMMANDS[subcommand];
if (!handler) return;
try {
await handler(interaction);
} catch (err) {
logError('response-command', err, interaction).catch(() => {});
const errorMsg = '❌ An error occurred while processing the response command.';
if (interaction.deferred) {
await interaction.editReply(errorMsg);
} else {
await interaction.reply({ content: errorMsg, flags: MessageFlags.Ephemeral });
}
}
}
async function handleResponseSend(interaction) {
const name = interaction.options.getString('name');
const tag = await Tag.findOne({ name }).lean();
if (!tag) {
return interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
}
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
const context = {
ticket: ticket || {},
staff: {
username: interaction.user.username,
displayName: interaction.member?.displayName,
mention: interaction.user.toString()
},
guild: interaction.guild
};
const content = replaceVariables(tag.content, context);
await Tag.updateOne({ name }, { $inc: { useCount: 1 } });
// Tag bodies are staff-authored but may include variable substitutions from user/ticket data.
// Disable mention parsing so a `@everyone` in a tag body never pings.
await interaction.reply({ content, allowedMentions: { parse: [] } });
}
async function handleResponseCreate(interaction) {
const name = interaction.options.getString('name');
const content = interaction.options.getString('content');
try {
await Tag.create({ name, content, createdBy: interaction.user.id });
await interaction.reply({ content: `✅ Tag "${name}" created successfully.`, flags: MessageFlags.Ephemeral });
} catch (err) {
if (err.code === 11000 || err.message?.includes('duplicate')) {
await interaction.reply({ content: `❌ Tag "${name}" already exists.`, flags: MessageFlags.Ephemeral });
} else {
logError('tag-create', err, interaction).catch(() => {});
await interaction.reply({ content: '❌ Failed to create tag.', flags: MessageFlags.Ephemeral });
}
}
}
async function handleResponseEdit(interaction) {
const name = interaction.options.getString('name');
const content = interaction.options.getString('content');
try {
const result = await Tag.updateOne({ name }, { $set: { content } });
if (result.matchedCount === 0) {
await interaction.reply({ content: `❌ Tag "${name}" not found.`, flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ content: `✅ Tag "${name}" updated successfully.`, flags: MessageFlags.Ephemeral });
}
} catch (err) {
logError('tag-edit', err, interaction).catch(() => {});
await interaction.reply({ content: '❌ Failed to edit tag.', flags: MessageFlags.Ephemeral });
}
}
async function handleResponseDelete(interaction) {
const name = interaction.options.getString('name');
// Use :: delimiter so tag names with underscores parse correctly (Discord customId max 100 chars).
const customId = `confirm_delete_tag::${name}`.slice(0, 100);
const confirmRow = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(customId)
.setLabel('Yes, Delete Tag')
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('cancel_delete_tag')
.setLabel('Cancel')
.setStyle(ButtonStyle.Secondary)
);
return interaction.reply({
content: `⚠️ Are you sure you want to delete the tag "${name}"? This action cannot be undone.`,
components: [confirmRow],
flags: MessageFlags.Ephemeral
});
}
async function handleResponseList(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const tags = await Tag.find().sort({ useCount: -1 }).select('name useCount').lean();
if (!tags || tags.length === 0) {
return interaction.editReply({ content: '📋 No tags available.' });
}
const embed = new EmbedBuilder()
.setTitle('📋 Available Saved Responses')
.setDescription(
tags.map((t, i) => `${i + 1}. **${t.name}** (used ${t.useCount || 0}x)`).join('\n')
)
.setColor(CONFIG.EMBED_COLOR_INFO)
.setFooter({ text: `Total: ${tags.length} tags` });
await interaction.editReply({ embeds: [embed] });
}
const RESPONSE_SUBCOMMANDS = {
send: handleResponseSend,
create: handleResponseCreate,
edit: handleResponseEdit,
delete: handleResponseDelete,
list: handleResponseList
};
/** Autocomplete handler. Currently only /response uses it. */
async function handleAutocomplete(interaction) {
if (interaction.commandName !== 'response') return;
const subcommand = interaction.options.getSubcommand();
if (!['send', 'edit', 'delete'].includes(subcommand)) return;
const focusedValue = interaction.options.getFocused();
const tags = await Tag.find().sort({ name: 1 }).select('name').lean();
const filtered = tags
.filter(t => t.name.toLowerCase().includes(focusedValue.toLowerCase()))
.slice(0, 25)
.map(t => ({ name: t.name, value: t.name }));
await interaction.respond(filtered);
}
module.exports = { handleResponse, handleAutocomplete };

View File

@@ -3,10 +3,11 @@
*/
const { mongoose } = require('../db-connection');
const { CONFIG } = require('../config');
const { extractRawEmail } = require('../utils');
const { extractRawEmail, isStaff } = require('../utils');
const { getGmailClient, sendGmailReply } = require('../services/gmail');
const { updateTicketActivity } = require('../services/tickets');
const { getNotifyDm } = require('../services/staffSettings');
const { logError } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
@@ -19,19 +20,22 @@ async function handleDiscordReply(m) {
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (!ticket) return;
// Track whether last message is from staff or customer
const memberForCheck = await m.guild.members.fetch(m.author.id).catch(() => null);
const isStaffMember = memberForCheck && CONFIG.ROLE_ID_TO_PING && memberForCheck.roles.cache.has(CONFIG.ROLE_ID_TO_PING);
const isStaffMember = isStaff(memberForCheck);
Ticket.updateOne(
{ discordThreadId: m.channel.id },
{ $set: { lastMessageAuthorIsStaff: !!isStaffMember, lastActivity: new Date() } }
).catch(() => {});
{ $set: { lastActivity: new Date() } }
).catch(err => logError('updateActivity', err).catch(() => {}));
// DM the claimer if they have notifydm on and a non-staff user replied.
if (ticket.claimerId && !isStaffMember && m.author.id !== ticket.claimerId) {
const dmEnabled = await getNotifyDm(ticket.claimerId);
if (dmEnabled) {
const staffMember = await m.guild.members.fetch(ticket.claimerId).catch(() => null);
// Cache-first: GuildMembers intent keeps the cache populated; only fetch
// on miss (e.g. cold cache after restart). Avoids a REST round-trip on
// every customer reply in a busy ticket.
const staffMember = m.guild.members.cache.get(ticket.claimerId)
|| await m.guild.members.fetch(ticket.claimerId).catch(() => null);
if (staffMember) {
const jumpLink = `https://discord.com/channels/${m.guild.id}/${m.channel.id}/${m.id}`;
await staffMember
@@ -43,8 +47,6 @@ async function handleDiscordReply(m) {
}
}
const discordUser = m.member?.displayName || m.author.username;
if (ticket.gmailThreadId.startsWith('discord-')) {
return;
}
@@ -88,7 +90,6 @@ async function handleDiscordReply(m) {
m.content,
recipientEmail,
subject,
discordUser,
msgId,
m.author.id
);

View File

@@ -1,656 +0,0 @@
/**
* /setup wizard multi-step panel configuration (panel name, support role,
* ticket category, transcript channel, panel channel).
*/
const {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
EmbedBuilder,
ChannelType,
ModalBuilder,
TextInputBuilder,
TextInputStyle,
RoleSelectMenuBuilder,
ChannelSelectMenuBuilder
} = require('discord.js');
const { CONFIG } = require('../config');
const { enqueueSend } = require('../services/channelQueue');
const TOTAL_STEPS = 5;
const WIZARD_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
/** @type {Map<string, { step: number, panelName?: string, roleIds?: string[], ticketType?: 'channel'|'thread', categoryId?: string, categoryName?: string, threadChannelId?: string, threadChannelName?: string, transcriptChannelId?: string, panelChannelId?: string, createdAt: number }>} */
const setupState = new Map();
const PREFIX = 'setup_';
const PREFIX_BUTTON = PREFIX;
const PREFIX_MODAL = PREFIX + 'modal_';
const PREFIX_SELECT = PREFIX + 'select_';
function getState(userId) {
const s = setupState.get(userId);
if (!s) return null;
if (Date.now() - s.createdAt > WIZARD_TIMEOUT_MS) {
setupState.delete(userId);
return null;
}
return s;
}
function setState(userId, data) {
const existing = setupState.get(userId) || { createdAt: Date.now() };
setupState.set(userId, { ...existing, ...data });
}
function clearState(userId) {
setupState.delete(userId);
}
function step1Embed(panelName) {
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 1/5 Set the panel name')
.setDescription(
'Use the button to set the panel name and continue.\n(This can be changed later.)'
)
.addFields({ name: 'Current Name', value: panelName ? `\`${panelName}\`` : 'Not set' });
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'setname')
.setLabel('Set name')
.setStyle(ButtonStyle.Primary)
.setEmoji('⚙️'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'continue_1')
.setLabel('Save & Continue')
.setStyle(ButtonStyle.Success)
.setDisabled(!panelName)
);
return { embeds: [embed], components: [row] };
}
function step2Embed(roleLabels) {
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 2/5 Select the support team role(s)')
.setDescription(
'The support roles will be automatically added to this panel\'s tickets so they can assist people as needed.\n' +
'Use the dropdown to select roles.\n' +
'Not seeing your role? Try searching for it inside the dropdown.'
)
.addFields({
name: 'Selected Role(s)',
value: roleLabels && roleLabels.length ? roleLabels.join(', ') : 'None selected'
});
const select = new RoleSelectMenuBuilder()
.setCustomId(PREFIX_SELECT + 'roles')
.setPlaceholder('Select all the roles for your support team')
.setMinValues(1)
.setMaxValues(5);
const row1 = new ActionRowBuilder().addComponents(select);
const row2 = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'back_2')
.setLabel('Back')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⬅️'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'continue_2')
.setLabel('Save & Continue')
.setStyle(ButtonStyle.Success)
.setDisabled(!roleLabels || roleLabels.length === 0)
);
return { embeds: [embed], components: [row1, row2] };
}
function step3Embed(state) {
const ticketType = state.ticketType;
const categoryName = state.categoryName;
const threadChannelName = state.threadChannelName;
if (!ticketType) {
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 3/5 How should tickets be created?')
.setDescription(
'**Channels:** Each ticket is a channel in a category (classic layout).\n' +
'**Threads:** Each ticket is a private thread under a text channel (compact).\n' +
'**Both:** Create one panel with two buttons (thread + category).'
)
.addFields({ name: 'Choice', value: 'Select below' });
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'tickettype_channel')
.setLabel('Channels in category')
.setStyle(ButtonStyle.Primary)
.setEmoji('📁'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'tickettype_thread')
.setLabel('Private threads')
.setStyle(ButtonStyle.Primary)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'tickettype_both')
.setLabel('Both (thread + category)')
.setStyle(ButtonStyle.Primary)
.setEmoji('📋'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'back_3')
.setLabel('Back')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⬅️')
);
return { embeds: [embed], components: [row] };
}
if (ticketType === 'both') {
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 3/5 Select category and thread channel (both)')
.setDescription(
'The panel will have two buttons: one creates ticket **threads**, one creates ticket **channels**.\n' +
'Select the category for channels and the text channel for threads.'
)
.addFields(
{ name: 'Category (for channels)', value: categoryName ? `\`${categoryName}\`` : 'None selected', inline: true },
{ name: 'Channel (for threads)', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected', inline: true }
);
const row1 = new ActionRowBuilder().addComponents(
new ChannelSelectMenuBuilder()
.setCustomId(PREFIX_SELECT + 'category')
.setPlaceholder('Select category for channels')
.addChannelTypes(ChannelType.GuildCategory)
.setMaxValues(1)
);
const row2 = new ActionRowBuilder().addComponents(
new ChannelSelectMenuBuilder()
.setCustomId(PREFIX_SELECT + 'thread_channel')
.setPlaceholder('Select channel for threads')
.addChannelTypes(ChannelType.GuildText)
.setMaxValues(1)
);
const row3 = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_both_channel')
.setLabel('Channels only')
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'tickettype_clear_thread')
.setLabel('Threads only')
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'back_3')
.setLabel('Back')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⬅️'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'continue_3')
.setLabel('Save & Continue')
.setStyle(ButtonStyle.Success)
.setDisabled(!(categoryName && threadChannelName))
);
return { embeds: [embed], components: [row1, row2, row3] };
}
if (ticketType === 'channel') {
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 3/5 Select the ticket category')
.setDescription(
'The selected category is where ticket **channels** will be created.\n' +
'Use the dropdown to select the category.'
)
.addFields({ name: 'Selected Category', value: categoryName ? `\`${categoryName}\`` : 'None selected' });
const select = new ChannelSelectMenuBuilder()
.setCustomId(PREFIX_SELECT + 'category')
.setPlaceholder('Select a category')
.addChannelTypes(ChannelType.GuildCategory)
.setMaxValues(1);
const row1 = new ActionRowBuilder().addComponents(select);
const row2 = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
.setLabel('Change to Threads')
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'back_3')
.setLabel('Back')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⬅️'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'continue_3')
.setLabel('Save & Continue')
.setStyle(ButtonStyle.Success)
.setDisabled(!categoryName)
);
return { embeds: [embed], components: [row1, row2] };
}
// ticketType === 'thread'
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 3/5 Select the channel for ticket threads')
.setDescription(
'Ticket **threads** will be created as private threads under the selected text channel.\n' +
'Use the dropdown to select the channel.'
)
.addFields({ name: 'Selected Channel', value: threadChannelName ? `\`${threadChannelName}\`` : 'None selected' });
const select = new ChannelSelectMenuBuilder()
.setCustomId(PREFIX_SELECT + 'thread_channel')
.setPlaceholder('Select a text channel')
.addChannelTypes(ChannelType.GuildText)
.setMaxValues(1);
const row1 = new ActionRowBuilder().addComponents(select);
const row2 = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'tickettype_clear')
.setLabel('Change to Channels')
.setStyle(ButtonStyle.Secondary),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'back_3')
.setLabel('Back')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⬅️'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'continue_3')
.setLabel('Save & Continue')
.setStyle(ButtonStyle.Success)
.setDisabled(!threadChannelName)
);
return { embeds: [embed], components: [row1, row2] };
}
function step4Embed(channelName) {
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 4/5 Select the transcript channel')
.setDescription(
'The selected channel is where transcripts will be saved when tickets are closed.\n' +
'Use the dropdown to select the channel.\n' +
'Not seeing your channel? Try searching for it inside the dropdown.'
)
.addFields({
name: 'Selected Channel',
value: channelName ? `\`${channelName}\`` : 'Not selected'
});
const select = new ChannelSelectMenuBuilder()
.setCustomId(PREFIX_SELECT + 'transcript')
.setPlaceholder('Select a channel')
.addChannelTypes(ChannelType.GuildText)
.setMaxValues(1);
const row1 = new ActionRowBuilder().addComponents(select);
const row2 = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'back_4')
.setLabel('Back')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⬅️'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'continue_4')
.setLabel('Save & Continue')
.setStyle(ButtonStyle.Success)
.setDisabled(!channelName)
);
return { embeds: [embed], components: [row1, row2] };
}
function step5Embed(channelName) {
const embed = new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Step 5/5 Send the panel into a channel')
.setDescription(
'The ticket creation panel is what the community will use to create tickets.\n' +
'Use the dropdown to select the channel to send the panel into.\n' +
'Not seeing your channel? Try searching for it inside the dropdown.\n' +
'Sending not working? Run `/panel` in the channel directly.'
)
.addFields({
name: 'Selected Channel',
value: channelName ? `\`${channelName}\`` : 'Not selected'
});
const select = new ChannelSelectMenuBuilder()
.setCustomId(PREFIX_SELECT + 'panel_channel')
.setPlaceholder('Select a channel')
.addChannelTypes(ChannelType.GuildText)
.setMaxValues(1);
const row1 = new ActionRowBuilder().addComponents(select);
const row2 = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'back_5')
.setLabel('Back')
.setStyle(ButtonStyle.Secondary)
.setEmoji('⬅️'),
new ButtonBuilder()
.setCustomId(PREFIX_BUTTON + 'finish')
.setLabel('Finish')
.setStyle(ButtonStyle.Success)
.setDisabled(!channelName)
);
return { embeds: [embed], components: [row1, row2] };
}
/**
* Handle /setup slash command send Step 1.
*/
async function handleSetupCommand(interaction) {
await interaction.deferReply({ ephemeral: true });
setState(interaction.user.id, { step: 1, panelName: null });
const payload = step1Embed(null);
await interaction.editReply(payload);
}
/**
* Handle setup button (Set name, Back, Save & Continue, Finish).
*/
async function handleSetupButton(interaction) {
const customId = interaction.customId;
if (!customId.startsWith(PREFIX_BUTTON)) return false;
const userId = interaction.user.id;
const state = getState(userId);
if (!state) {
await interaction.reply({
content: 'This setup session has expired. Run `/setup` again.',
ephemeral: true
}).catch(() => {});
return true;
}
// Set name → show modal
if (customId === PREFIX_BUTTON + 'setname') {
const modal = new ModalBuilder()
.setCustomId(PREFIX_MODAL + 'name')
.setTitle('Panel name');
const input = new TextInputBuilder()
.setCustomId('panel_name')
.setLabel('Panel name')
.setStyle(TextInputStyle.Short)
.setPlaceholder('e.g. New Panel')
.setRequired(true)
.setMaxLength(100);
if (state.panelName) input.setValue(state.panelName);
modal.addComponents(new ActionRowBuilder().addComponents(input));
await interaction.showModal(modal);
return true;
}
// Back
if (customId.startsWith(PREFIX_BUTTON + 'back_')) {
const step = parseInt(customId.replace(PREFIX_BUTTON + 'back_', ''), 10);
const nextStep = step - 1;
setState(userId, { step: nextStep });
let payload;
if (nextStep === 1) payload = step1Embed(state.panelName);
else if (nextStep === 2) payload = step2Embed(state.roleLabels);
else if (nextStep === 3) payload = step3Embed(state);
else if (nextStep === 4) payload = step4Embed(state.transcriptChannelName);
else payload = step5Embed(state.panelChannelName);
await interaction.update(payload);
return true;
}
// Save & Continue (steps 14)
if (customId === PREFIX_BUTTON + 'continue_1') {
setState(userId, { step: 2 });
await interaction.update(step2Embed(state.roleLabels));
return true;
}
if (customId === PREFIX_BUTTON + 'continue_2') {
setState(userId, { step: 3 });
await interaction.update(step3Embed({ ...state, step: 3 }));
return true;
}
if (customId === PREFIX_BUTTON + 'tickettype_channel') {
setState(userId, { ticketType: 'channel', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_BUTTON + 'tickettype_thread') {
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_BUTTON + 'tickettype_both') {
setState(userId, { ticketType: 'both', categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_BUTTON + 'tickettype_clear') {
setState(userId, { ticketType: null, categoryId: null, categoryName: null, threadChannelId: null, threadChannelName: null });
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_BUTTON + 'tickettype_clear_thread') {
setState(userId, { ticketType: 'thread', categoryId: null, categoryName: null });
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_BUTTON + 'tickettype_clear_both_channel') {
setState(userId, { ticketType: 'channel', threadChannelId: null, threadChannelName: null });
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_BUTTON + 'continue_3') {
setState(userId, { step: 4 });
await interaction.update(step4Embed(state.transcriptChannelName));
return true;
}
if (customId === PREFIX_BUTTON + 'continue_4') {
setState(userId, { step: 5 });
await interaction.update(step5Embed(state.panelChannelName));
return true;
}
// Finish
if (customId === PREFIX_BUTTON + 'finish') {
const hasTicketTarget =
(state.ticketType === 'channel' && state.categoryId) ||
(state.ticketType === 'thread' && state.threadChannelId) ||
(state.ticketType === 'both' && state.categoryId && state.threadChannelId);
if (!state.panelChannelId || !hasTicketTarget || !state.roleIds?.length) {
await interaction.reply({
content: 'Please complete all steps (panel name, support role, ticket type + category/channel, transcript channel, panel channel).',
ephemeral: true
}).catch(() => {});
return true;
}
try {
const channel = await interaction.client.channels.fetch(state.panelChannelId);
const title = state.panelName || 'Indifferent Broccoli Tickets';
const description = 'Need help? Click below to create a ticket. 🎟';
const embed = new EmbedBuilder()
.setTitle(title)
.setDescription(description)
.setColor(0x2ecc71)
.setThumbnail(CONFIG.LOGO_URL || null)
.setFooter({ text: 'Indifferent Broccoli Tickets' });
let row;
if (state.ticketType === 'both') {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket_thread')
.setLabel('Create ticket (thread)')
.setStyle(ButtonStyle.Success)
.setEmoji('🧵'),
new ButtonBuilder()
.setCustomId('open_ticket_channel')
.setLabel('Create ticket (channel)')
.setStyle(ButtonStyle.Success)
.setEmoji('📁')
);
} else {
row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('open_ticket')
.setLabel('Create ticket')
.setStyle(ButtonStyle.Success)
.setEmoji('✅')
);
}
await enqueueSend(channel, { embeds: [embed], components: [row] });
const envLines = state.ticketType === 'both'
? [`DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`, `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`]
: [state.ticketType === 'thread'
? `DISCORD_THREAD_CHANNEL_ID=${state.threadChannelId}`
: `DISCORD_TICKET_CATEGORY_ID=${state.categoryId}`];
const envSnippet = [
'**Add these to your `.env` file** (optional only if you want to use these values for new Discord tickets):',
'```',
...envLines,
`ROLE_ID_TO_PING=${state.roleIds[0]}`,
`TRANSCRIPT_CHANNEL_ID=${state.transcriptChannelId}`,
`LOGGING_CHANNEL_ID=${state.transcriptChannelId}`,
'```'
].join('\n');
await interaction.update({
embeds: [
new EmbedBuilder()
.setColor(0x2ecc71)
.setTitle('Setup complete')
.setDescription(
`Panel **${title}** has been sent to ${channel}.\n\n` +
envSnippet
)
],
components: []
});
} catch (err) {
console.error('Setup finish error:', err);
await interaction.reply({
content: `Failed to send panel: ${err.message}`,
ephemeral: true
}).catch(() => {});
}
clearState(userId);
return true;
}
return false;
}
/**
* Handle setup modal submit (panel name).
*/
async function handleSetupModal(interaction) {
if (!interaction.customId.startsWith(PREFIX_MODAL)) return false;
const userId = interaction.user.id;
const state = getState(userId);
if (!state) {
await interaction.reply({
content: 'This setup session has expired. Run `/setup` again.',
ephemeral: true
}).catch(() => {});
return true;
}
if (interaction.customId === PREFIX_MODAL + 'name') {
const panelName = interaction.fields.getTextInputValue('panel_name').trim();
setState(userId, { panelName, step: 1 });
await interaction.deferReply({ ephemeral: true });
const payload = step1Embed(panelName);
await interaction.editReply(payload);
return true;
}
return false;
}
/**
* Handle setup select menus (roles, category, transcript channel, panel channel).
*/
async function handleSetupSelect(interaction) {
const customId = interaction.customId;
if (!customId.startsWith(PREFIX_SELECT)) return false;
const userId = interaction.user.id;
const state = getState(userId);
if (!state) {
await interaction.reply({
content: 'This setup session has expired. Run `/setup` again.',
ephemeral: true
}).catch(() => {});
return true;
}
if (customId === PREFIX_SELECT + 'roles') {
const roles = interaction.roles;
const roleIds = [...roles.keys()];
const roleLabels = [...roles.values()].map(r => r.name);
setState(userId, { roleIds, roleLabels });
await interaction.update(step2Embed(roleLabels));
return true;
}
if (customId === PREFIX_SELECT + 'category') {
const channel = interaction.channels.first();
setState(userId, {
categoryId: channel?.id,
categoryName: channel?.name
});
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_SELECT + 'thread_channel') {
const channel = interaction.channels.first();
setState(userId, {
threadChannelId: channel?.id,
threadChannelName: channel?.name
});
await interaction.update(step3Embed(getState(userId)));
return true;
}
if (customId === PREFIX_SELECT + 'transcript') {
const channel = interaction.channels.first();
setState(userId, {
transcriptChannelId: channel?.id,
transcriptChannelName: channel?.name
});
await interaction.update(step4Embed(channel?.name));
return true;
}
if (customId === PREFIX_SELECT + 'panel_channel') {
const channel = interaction.channels.first();
setState(userId, {
panelChannelId: channel?.id,
panelChannelName: channel?.name
});
await interaction.update(step5Embed(channel?.name));
return true;
}
return false;
}
module.exports = {
PREFIX_BUTTON,
PREFIX_MODAL,
PREFIX_SELECT,
handleSetupCommand,
handleSetupButton,
handleSetupModal,
handleSetupSelect
};

54
handlers/sharedHelpers.js Normal file
View File

@@ -0,0 +1,54 @@
/**
* Shared helpers for slash-command and button handlers.
*
* Both handlers/commands.js and handlers/buttons.js use these to avoid
* repeating the lookup-and-defer-and-try-catch pattern across 30+ branches.
*/
const { MessageFlags } = require('discord.js');
const { mongoose } = require('../db-connection');
const { logError } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
/**
* Look up the ticket linked to this channel; reply with `missingMessage`
* (default: "This channel is not linked to a ticket.") and return null if
* the channel is not a ticket. Returns the ticket on success.
*
* @param {import('discord.js').Interaction} interaction
* @param {string} [missingMessage]
*/
async function findTicketForChannel(interaction, missingMessage = 'This channel is not linked to a ticket.') {
const ticket = await Ticket.findOne({ discordThreadId: interaction.channel.id }).lean();
if (!ticket) {
await interaction.reply({ content: missingMessage, flags: MessageFlags.Ephemeral });
return null;
}
return ticket;
}
/**
* Defer + run + log + reply on error. `verb` is the user-facing verb
* (e.g. "escalate"); error messages render as "Failed to <verb> this ticket."
* Errors are logged to console + DEBUGGING_CHANNEL_ID via logError(verb, ...).
*
* @param {import('discord.js').Interaction} interaction
* @param {string} verb
* @param {() => Promise<void>} fn
* @param {{ flags?: number }} [opts] - pass `MessageFlags.Ephemeral` for ephemeral defer
*/
async function runDeferred(interaction, verb, fn, { flags } = {}) {
try {
await interaction.deferReply(flags ? { flags } : {});
await fn();
} catch (err) {
console.error(`${verb} error:`, err);
logError(verb, err, interaction).catch(() => {});
const msg = `Failed to ${verb} this ticket.`;
await interaction.editReply({ content: msg }).catch(() =>
interaction.followUp({ content: msg, flags: MessageFlags.Ephemeral }).catch(() => {})
);
}
}
module.exports = { findTicketForChannel, runDeferred };

View File

@@ -5,8 +5,6 @@ var mongoose = require('mongoose');
const ticketSchema = new mongoose.Schema({
gmailThreadId: { type: String, required: true, unique: true, index: true },
discordThreadId: String,
broccoliniTicketId: Number,
lastSyncedBroccoliniArticleId: Number,
senderEmail: { type: String, required: true },
subject: String,
createdAt: { type: Date, default: Date.now },
@@ -16,18 +14,13 @@ const ticketSchema = new mongoose.Schema({
escalated: { type: Boolean, default: false },
escalationTier: { type: Number, default: 0 },
ticketNumber: Number,
renameCount: { type: Number, default: 0 },
renameWindowStart: Date,
priority: { type: String, default: 'normal', enum: ['low', 'normal', 'medium', 'high'] },
ticketTag: String,
lastActivity: Date,
reminderSent: { type: Boolean, default: false },
welcomeMessageId: String,
claimerId: String,
staffChannelId: String,
creatorId: String,
parentCategoryId: String,
unclaimedRemindersSent: { type: [Number], default: [] },
lastMessageAuthorIsStaff: { type: Boolean, default: false },
pendingDelete: { type: Boolean, default: false }
});
ticketSchema.index({ status: 1, lastActivity: 1 });
@@ -54,12 +47,6 @@ mongoose.model('Tag', new mongoose.Schema({
useCount: { type: Number, default: 0 }
}));
mongoose.model('GuildSettings', new mongoose.Schema({
guildId: { type: String, required: true, unique: true },
emailRouting: { type: String, enum: ['thread', 'category'], default: 'category' },
updatedAt: { type: Date, default: Date.now }
}));
mongoose.model('StaffSettings', new mongoose.Schema({
userId: { type: String, required: true, unique: true },
guildId: { type: String, required: true },

1596
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,14 @@
"dependencies": {
"discord.js": "^14.25.1",
"dotenv": "^17.2.4",
"dotenv-expand": "^11.0.6",
"express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"googleapis": "^171.4.0",
"mongoose": "^8.23.1"
},
"devDependencies": {
"mongodb": "^7.1.0",
"mongoose": "^6.12.0",
"p-queue": "^6.6.2"
"vitest": "^4.1.5"
},
"name": "broccolini-bot",
"version": "1.0.0",
@@ -16,6 +17,7 @@
"main": "broccolini-discord.js",
"scripts": {
"start": "node broccolini-discord.js",
"test": "vitest run",
"test-mongodb": "node scripts/test-mongodb.js"
},
"keywords": [],

View File

@@ -4,7 +4,6 @@ const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config');
const { safeEqual } = require('../utils');
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
const { logSystem } = require('../services/debugLog');
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
const router = express.Router();
@@ -20,6 +19,17 @@ const internalLimiter = rateLimit({
message: { error: 'Too many requests, please try again later.' }
});
// /restart calls process.exit; defense-in-depth tighter floor in case the
// shared INTERNAL_API_SECRET ever leaks. 2/min is enough for an operator-
// driven retry but not enough to crash-loop the container.
const restartLimiter = rateLimit({
windowMs: 60 * 1000,
max: 2,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many restart attempts.' }
});
router.use(internalLimiter);
// Middleware: verify internal secret
@@ -54,11 +64,6 @@ router.post('/config', express.json(), async (req, res) => {
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
}
const result = applyConfigUpdates(updates);
const errorSummary = result.errors.map(e => `${e.key}: ${e.error}`).join(', ');
await logSystem('Config updated via settings UI', [
{ name: 'Keys updated', value: result.applied.join(', ') || 'none', inline: false },
{ name: 'Errors', value: errorSummary || 'none', inline: false }
]).catch(() => {});
// Partial success stays 200 so the client can still apply the successful keys.
// Only 400 when every submitted key failed validation (i.e. the save did nothing).
const totalSubmitted = Object.keys(updates).length;
@@ -69,7 +74,7 @@ router.post('/config', express.json(), async (req, res) => {
// GET /discord/guild — return guild info for smart dropdowns
router.get('/discord/guild', async (req, res) => {
try {
const client = require('../api/botClient').getBot();
const client = require('../broccolini-discord').client;
if (!client) return res.status(503).json({ error: 'Bot not ready' });
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
@@ -117,7 +122,7 @@ router.get('/discord/guild', async (req, res) => {
// POST /restart — restart the bot process
let scheduledRestart = null;
router.post('/restart', express.json(), (req, res) => {
router.post('/restart', restartLimiter, express.json(), (req, res) => {
const { mode, scheduledFor } = req.body;
if (mode === 'immediate') {
@@ -178,9 +183,6 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
if (parent.setGmailPollInterval) {
parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS);
}
await logSystem('Gmail OAuth reloaded', [
{ name: 'Account', value: emailAddress, inline: false }
]).catch(() => {});
res.json({ ok: true, email: emailAddress });
} catch (err) {
const oauthError = err && err.response && err.response.data && err.response.data.error;

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* One-shot backfill for Ticket.creatorId on Discord-originated tickets.
*
* Modal-created tickets (`discord-${ts}-${userId}`): tail segment is the user ID — extract it.
* Context-menu tickets (`discord-msg-${ts}-${msgId}`): tail segment is the *message* ID, not the
* user ID. Set creatorId = null and let runtime code fall through to the default-name path.
* Recovering these would require a Discord API fetch per message, which is unreliable for
* already-deleted ticket channels.
*
* Idempotent: skips tickets that already have creatorId set.
*
* Usage:
* node scripts/backfill-creatorId.js # dry-run, prints summary only
* node scripts/backfill-creatorId.js --apply # writes
*/
require('dotenv').config();
const { connectMongoDB, closeMongoDB, mongoose } = require('../db-connection');
const APPLY = process.argv.includes('--apply');
const MODAL_RE = /^discord-\d+-(\d{17,20})$/;
async function main() {
if (!process.env.MONGODB_URI) {
console.error('MONGODB_URI not set');
process.exit(1);
}
await connectMongoDB(process.env.MONGODB_URI);
const Ticket = mongoose.model('Ticket');
const candidates = await Ticket.find({
gmailThreadId: /^discord-/,
creatorId: { $in: [null, undefined, ''] }
}).select('gmailThreadId creatorId').lean();
let modalHits = 0;
let msgSkipped = 0;
let unknown = 0;
const ops = [];
for (const t of candidates) {
const id = t.gmailThreadId;
const modalMatch = id.match(MODAL_RE);
if (modalMatch) {
modalHits++;
ops.push({
updateOne: {
filter: { _id: t._id },
update: { $set: { creatorId: modalMatch[1] } }
}
});
continue;
}
if (id.startsWith('discord-msg-')) {
msgSkipped++;
continue;
}
unknown++;
}
console.log(`Scanned ${candidates.length} Discord-originated tickets without creatorId.`);
console.log(` Modal-pattern recoverable: ${modalHits}`);
console.log(` Context-menu (unrecoverable, leaving null): ${msgSkipped}`);
console.log(` Unknown shape: ${unknown}`);
if (!APPLY) {
console.log('\nDry-run only. Re-run with --apply to write changes.');
await closeMongoDB();
return;
}
if (ops.length === 0) {
console.log('Nothing to write.');
await closeMongoDB();
return;
}
const res = await Ticket.bulkWrite(ops, { ordered: false });
console.log(`Wrote ${res.modifiedCount} updates.`);
await closeMongoDB();
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -25,10 +25,13 @@ async function executeRename(channel, entry) {
// (403), or no token configured — fall back to the primary Discord.js client.
// Non-fallback errors rethrow so enqueueRename's catch can classify/log.
if (err && err.fallback === true && channel && typeof channel.setName === 'function') {
logWarn(
'renameQueue',
`secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
).catch(() => {});
// Local log only; discord.js's REST client transparently handles 429s
// on the primary fallback, so this used to post a paired warning to
// the debug channel for every secondary-bot quota event with no
// operator action required. Keep the visibility in container logs.
console.warn(
`[renameQueue] secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}`
);
await channel.setName(currentName);
} else {
throw err;
@@ -80,6 +83,12 @@ function enqueueRename(channel, newName) {
// Shares renameChains so a move+rename pair on the same channel executes in
// call order. No coalescing: every move is a distinct chain link.
//
// lockPermissions: false preserves the channel's existing permission overwrites
// across the parent change. With the default (true), Discord re-syncs the
// channel's overwrites to match the new category and wipes per-user grants —
// in practice that kicked the ticket creator and any /add'd users off the
// channel on every escalate / de-escalate / /move.
function enqueueMove(channel, categoryId) {
let entry = renameChains.get(channel.id);
if (!entry) {
@@ -87,7 +96,7 @@ function enqueueMove(channel, categoryId) {
renameChains.set(channel.id, entry);
}
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: true }));
const next = entry.chain.catch(() => {}).then(() => channel.setParent(categoryId, { lockPermissions: false }));
entry.chain = next;
next.catch((err) => {
@@ -113,6 +122,81 @@ function enqueueMove(channel, categoryId) {
return next;
}
// Shares renameChains so a permissionOverwrite mutation serializes with pending
// renames/moves on the same channel. Mode 'create' calls
// `channel.permissionOverwrites.create(id, perms)`; 'delete' calls
// `channel.permissionOverwrites.delete(id)`. No coalescing.
function enqueueOverwrite(channel, id, perms, mode = 'create') {
let entry = renameChains.get(channel.id);
if (!entry) {
entry = { chain: Promise.resolve(), pendingName: null };
renameChains.set(channel.id, entry);
}
const next = entry.chain.catch(() => {}).then(() =>
mode === 'delete'
? channel.permissionOverwrites.delete(id)
: channel.permissionOverwrites.create(id, perms)
);
entry.chain = next;
next.catch((err) => {
logWarn('overwriteQueue', `Overwrite ${mode} failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
const status = err && err.status;
const msg = (err && err.message) || String(err);
if (status === 401 || status === 403) {
logError(
'overwriteQueue:token/permission',
new Error(`${status} channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
).catch(() => {});
} else if (status === 429) {
logError(
'overwriteQueue:ratelimited',
new Error(`429 channel=${channel.id} target=${id} mode=${mode}: ${msg}`)
).catch(() => {});
}
}).finally(() => {
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
renameChains.delete(channel.id);
}
});
return next;
}
// Shares renameChains so setTopic serializes with pending renames/moves.
function enqueueTopic(channel, text) {
let entry = renameChains.get(channel.id);
if (!entry) {
entry = { chain: Promise.resolve(), pendingName: null };
renameChains.set(channel.id, entry);
}
const next = entry.chain.catch(() => {}).then(() => channel.setTopic(text));
entry.chain = next;
next.catch((err) => {
logWarn('topicQueue', `Topic set failed for ${channel.name}: ${err && err.message || err}`).catch(() => {});
const status = err && err.status;
const msg = (err && err.message) || String(err);
if (status === 401 || status === 403) {
logError(
'topicQueue:token/permission',
new Error(`${status} channel=${channel.id}: ${msg}`)
).catch(() => {});
} else if (status === 429) {
logError(
'topicQueue:ratelimited',
new Error(`429 channel=${channel.id}: ${msg}`)
).catch(() => {});
}
}).finally(() => {
if (renameChains.get(channel.id) === entry && entry.chain === next && entry.pendingName == null) {
renameChains.delete(channel.id);
}
});
return next;
}
// Per-channel promise chain for send ordering and to prevent interleaving.
const sendChains = new Map();
@@ -157,4 +241,4 @@ function enqueueDelete(channel) {
return next;
}
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete };
module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };

View File

@@ -150,7 +150,16 @@ function writeEnvFile(updates) {
const roundtrip = readEnvFile();
if (roundtrip.size !== expected) {
throw new Error(`writeEnvFile: key count mismatch after write (expected ${expected}, got ${roundtrip.size})`);
const expectedKeys = new Set(updates.keys());
const actualKeys = new Set(roundtrip.keys());
const missing = [...expectedKeys].filter(k => !actualKeys.has(k));
const extra = [...actualKeys].filter(k => !expectedKeys.has(k));
throw new Error(
`writeEnvFile: key count mismatch after write ` +
`(expected ${expected}, got ${roundtrip.size})` +
(missing.length ? `. Missing: [${missing.join(', ')}]` : '') +
(extra.length ? `. Extra: [${extra.join(', ')}]` : '')
);
}
}

View File

@@ -19,9 +19,7 @@
const ALLOWED_CONFIG_KEYS = new Set([
// Ticket settings
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME',
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
'DISCORD_THREAD_CHANNEL_ID', 'EMAIL_THREAD_CHANNEL_ID', 'THREAD_PARENT_CHANNEL', 'USE_THREADS',
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID',
// Escalation categories
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
@@ -30,13 +28,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'ADMIN_ID',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'DISCORD_CHANNEL_ID',
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
'RENAME_LOG_CHANNEL_ID',
// Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
'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_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding
@@ -48,11 +44,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'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',
// Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY',
'GLOBAL_TICKET_LIMIT',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
// Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', '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'
]);
@@ -203,32 +199,7 @@ function getValidator(key) {
return VALIDATORS[inferType(key)];
}
// Pre-build per-key validator map for callers that want O(1) lookup
// (and for the smoke test / boot log).
const ALL_VALIDATORS = {};
for (const key of ALLOWED_CONFIG_KEYS) {
ALL_VALIDATORS[key] = getValidator(key);
}
// ---------- Startup log (no-op if console.log is suppressed) ----------
(function logDistribution() {
const dist = {};
const fallback = [];
for (const [key, v] of Object.entries(ALL_VALIDATORS)) {
dist[v.type] = (dist[v.type] || 0) + 1;
if (v.type === 'string') fallback.push(key);
}
console.log('[configSchema] type distribution:', JSON.stringify(dist));
if (fallback.length) {
console.log(`[configSchema] ${fallback.length} keys use fallback 'string' validator:`, fallback.join(', '));
}
})();
module.exports = {
ALLOWED_CONFIG_KEYS,
VALIDATORS,
ALL_VALIDATORS,
getValidator,
inferType
getValidator
};

View File

@@ -11,6 +11,24 @@ function setClient(c) {
client = c;
}
// --- PII redaction ---
// Email addresses (loose regex — covers most RFC 5321 local parts that show up
// in support traffic) and Discord snowflakes (1820 digit numeric IDs) get
// redacted before stack/message text reaches the debug channel. Both can land
// in error stacks via senderEmail interpolation, channel IDs in error
// messages, etc. — redacting at the boundary keeps the debug channel useful
// for triage without leaking customer addresses or staff member IDs.
const EMAIL_REDACT_RE = /[\w.+-]+@[\w.-]+\.\w+/g;
const SNOWFLAKE_REDACT_RE = /\b\d{18,20}\b/g;
function redactPII(s) {
if (s == null) return '';
return String(s)
.replace(EMAIL_REDACT_RE, '[EMAIL_REDACTED]')
.replace(SNOWFLAKE_REDACT_RE, '[ID_REDACTED]');
}
// --- Helpers ---
async function sendToChannel(channelId, embed, overrideClient) {
@@ -38,9 +56,10 @@ async function logError(context, error, interaction = null, overrideClient = nul
const commandLine = (interaction?.commandName || interaction?.customId)
? `Command/Button: ${interaction.commandName || interaction.customId}\n`
: '';
const stack = (error.stack || error.message || String(error)).slice(0, 1500);
const message = redactPII(error.message || String(error));
const stack = redactPII(error.stack || error.message || String(error)).slice(0, 1500);
await channel.send({
content: `\`[${context}]\` ${error.message || String(error)}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
});
} catch (_) {
// ignore send failures
@@ -52,19 +71,12 @@ async function logError(context, error, interaction = null, overrideClient = nul
async function logWarn(context, message, overrideClient = null) {
const embed = new EmbedBuilder()
.setTitle(`Warning: ${context}`)
.setDescription(String(message).slice(0, 4000))
.setDescription(redactPII(String(message)).slice(0, 4000))
.setColor(0xFFFF00)
.setTimestamp();
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
}
// --- logEvent (generic posts to any configured channel) ---
async function logEvent(channelConfigKey, embed, overrideClient = null) {
const channelId = CONFIG[channelConfigKey];
await sendToChannel(channelId, embed, overrideClient);
}
// --- logTicketEvent ---
async function logTicketEvent(action, fields, interaction = null) {
@@ -76,95 +88,12 @@ async function logTicketEvent(action, fields, interaction = null) {
if (interaction?.user?.tag) {
embed.setFooter({ text: interaction.user.tag });
}
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client);
}
// --- logGmail ---
async function logGmail(subject, sender, ticketNumber, game) {
const embed = new EmbedBuilder()
.setTitle('Email Ticket Created')
.setColor(0x00BFFF)
.addFields(
{ name: 'Subject', value: String(subject || 'No subject').slice(0, 256), inline: false },
{ name: 'Sender', value: String(sender || 'unknown'), inline: true },
{ name: 'Ticket #', value: String(ticketNumber || '?'), inline: true },
{ name: 'Game', value: String(game || 'Not detected'), inline: true }
)
.setTimestamp();
await sendToChannel(CONFIG.GMAIL_LOG_CHANNEL_ID, embed);
}
// --- logAutomation ---
async function logAutomation(action, ticketChannelName, detail) {
const embed = new EmbedBuilder()
.setTitle(action)
.setColor(0x9B59B6)
.setTimestamp();
if (ticketChannelName) {
embed.addFields({ name: 'Ticket', value: String(ticketChannelName), inline: true });
}
if (detail) {
embed.addFields({ name: 'Detail', value: String(detail).slice(0, 1024), inline: false });
}
await sendToChannel(CONFIG.AUTOMATION_LOG_CHANNEL_ID, embed);
}
// --- logSecurity ---
async function logSecurity(action, user, detail, overrideClient = null, color = 0xFF6600) {
const embed = new EmbedBuilder()
.setTitle('Security Event')
.setColor(color)
.addFields(
{ name: 'Action', value: String(action).slice(0, 256), inline: false },
{ name: 'User', value: user ? `${user.tag} (${user.id})` : 'Unknown', inline: true },
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
)
.setTimestamp();
await sendToChannel(CONFIG.SECURITY_LOG_CHANNEL_ID, embed, overrideClient);
}
// --- logIntegrity ---
async function logIntegrity(issue, detail, overrideClient = null) {
const embed = new EmbedBuilder()
.setTitle('Ticket Integrity Issue')
.setColor(0xFF0000)
.addFields(
{ name: 'Issue', value: String(issue).slice(0, 256), inline: false },
{ name: 'Detail', value: String(detail || 'N/A').slice(0, 1024), inline: false },
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
)
.setTimestamp();
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient);
}
// --- logSystem ---
async function logSystem(message, fields = [], overrideClient = null, color = 0x0099ff) {
const embed = new EmbedBuilder()
.setTitle(message)
.setColor(color)
.setTimestamp();
if (fields.length > 0) {
embed.addFields(fields.map(f => ({ name: f.name, value: String(f.value).slice(0, 1024), inline: f.inline ?? true })));
}
embed.addFields({ name: 'Timestamp', value: new Date().toISOString(), inline: true });
await sendToChannel(CONFIG.SYSTEM_LOG_CHANNEL_ID, embed, overrideClient);
await sendToChannel(CONFIG.LOGGING_CHANNEL_ID, embed, interaction?.client);
}
module.exports = {
setClient,
logError,
logWarn,
logEvent,
logTicketEvent,
logGmail,
logAutomation,
logSecurity,
logIntegrity,
logSystem
logTicketEvent
};

View File

@@ -1,5 +1,5 @@
/**
* Gmail service OAuth client, send reply, send ticket-closed email.
* Gmail service OAuth client, send reply, send ticket-closed/notification emails.
*/
const { google } = require('googleapis');
const { CONFIG } = require('../config');
@@ -11,6 +11,35 @@ const { readEnvFile } = require('./configPersistence');
function sanitizeHeaderValue(v) { return String(v || '').replace(/[\r\n]+/g, ' ').trim(); }
const EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
function buildCompanySigHtml() {
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
return `
<table border="0" cellpadding="0" cellspacing="0" style="margin-top: 16px;">
<tr>
<td style="padding-right: 12px; vertical-align: top;">
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65" alt="Indifferent Broccoli">` : ''}
</td>
<td style="border-left: 1px solid #ddd; padding-left: 12px; vertical-align: top; font-size: 13px; color: #333;">
Indifferent Broccoli Support<br>
<a href="https://indifferentbroccoli.com/">https://indifferentbroccoli.com/</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>
</td>
</tr>
</table>`;
}
function buildCompanySigText() {
return [
'Indifferent Broccoli Support',
'https://indifferentbroccoli.com/',
'Join us on Discord: https://discord.gg/2vmfrrtvJY',
'',
"Host your own game server. Or not... we don't care."
].join('\n');
}
function getGmailClient() {
const auth = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
@@ -27,8 +56,6 @@ function getGmailClient() {
*
* Throws if the env file is missing the token, or if the probe call (getProfile)
* fails — the caller surfaces the error so the UI can see why.
*
* @returns {Promise<{emailAddress: string}>}
*/
async function reloadGmailClient() {
const envMap = readEnvFile();
@@ -45,188 +72,136 @@ async function reloadGmailClient() {
return { emailAddress: profile.data.emailAddress };
}
async function sendTicketClosedEmail(ticket, discordDisplayName) {
// Fetch the first message's Subject + Message-ID from a Gmail thread, used to
// derive a faithful Re: subject and a proper In-Reply-To/References header.
async function fetchThreadSubjectAndMsgId(gmail, threadId) {
try {
const gmail = getGmailClient();
// Send to the ticket sender (customer), not derived from thread (which can be support)
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
if (!EMAIL_RE.test(recipientEmail)) {
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
return;
}
let subjectHeader = ticket.subject || 'Support';
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const lastMsg = [...messages].reverse()[0];
if (lastMsg?.payload?.headers) {
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) subjectHeader = subj;
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
const thread = await gmail.users.threads.get({ userId: 'me', id: threadId });
const firstMsg = (thread.data.messages || [])[0];
const headers = firstMsg?.payload?.headers || [];
return {
subject: headers.find(h => h.name === 'Subject')?.value || null,
msgId: sanitizeHeaderValue(headers.find(h => h.name === 'Message-ID')?.value) || null
};
} catch (_) {
/* use ticket.subject and no In-Reply-To if thread fetch fails */
return { subject: null, msgId: null };
}
}
const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(
finalSubject
).toString('base64')}?=`;
// Strip leading "Re:" variants and re-prepend a single one, then RFC 2047 encode.
function encodeReplySubject(baseSubject) {
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safe = sanitizeHeaderValue(`Re: ${stripped}`);
return `=?utf-8?B?${Buffer.from(safe).toString('base64')}?=`;
}
// Compose and send a multipart/alternative reply on an existing Gmail thread.
async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) {
const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = sigBlocks.text;
const serverDisplayName = escapeHtml(discordDisplayName || CONFIG.SUPPORT_NAME || 'Support');
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
<p><strong>Message:</strong></p>
<p>${safeCloseMessage}</p>
<p style="margin-top: 16px;">${safeCloseSignature}</p>
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 12px;">
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
</td>
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
</td>
</tr>
</table>
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()}
</div>`;
const rawHeaders = [
const plainBody = [messageText || ''];
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
plainBody.push('', ...buildCompanySigText().split('\n'));
const boundary = '000000000000' + Date.now().toString(16);
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
msgId ? `In-Reply-To: ${msgId}` : '',
msgId ? `References: ${msgId}` : '',
`To: ${recipient}`,
`Subject: ${encodedSubject}`,
msgId && `In-Reply-To: ${msgId}`,
msgId && `References: ${msgId}`,
'MIME-Version: 1.0',
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody
`Content-Type: multipart/alternative; boundary="${boundary}"`
].filter(Boolean);
const raw = Buffer.from(rawHeaders.join('\r\n'))
const raw = Buffer.from([
...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')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw, threadId: ticket.gmailThreadId }
await gmail.users.messages.send({ userId: 'me', requestBody: { raw, threadId } });
}
// Resolve and validate a customer recipient from a ticket's senderEmail.
// Returns null and logs if invalid or self-addressed.
function resolveCustomerRecipient(ticket, context) {
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return null;
if (!EMAIL_RE.test(recipientEmail)) {
logError(`${context}: invalid recipient`, new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
return null;
}
return recipientEmail;
}
async function sendTicketClosedEmail(ticket, closerName, userId = null) {
try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketClosedEmail');
if (!recipient) return;
const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
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.`;
await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId,
recipient,
encodedSubject,
msgId,
messageText,
userId
});
} catch (err) {
console.error('Ticket closed email error:', err);
}
}
// StaffSignature model is registered in models.js; re-import here for use in this file
const { mongoose } = require('../db-connection');
const StaffSignature = mongoose.model('StaffSignature');
/**
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
* @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated")
* @param {string} subjectLine - Fallback subject if the thread can't be queried
* @param {string} messageBody - Plain or HTML message body
* @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord")
* @param {string} [userId] - Discord user ID for signature (optional)
*/
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel, userId = null) {
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) {
try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
if (!recipient) return;
const gmail = getGmailClient();
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
if (!EMAIL_RE.test(recipientEmail)) {
logError('sendTicketNotificationEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
return;
}
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support');
let subjectHeader = ticket.subject || 'Support';
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const lastMsg = [...messages].reverse()[0];
if (lastMsg?.payload?.headers) {
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) subjectHeader = subj;
msgId = sanitizeHeaderValue(lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
const finalSubject = sanitizeHeaderValue(subjectLine || subjectHeader);
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
const label = escapeHtml(fromLabel || CONFIG.SUPPORT_NAME || 'Support');
const safeBody = escapeHtml(messageBody || '').replace(/\n/g, '<br>');
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
// Get staff signature if userId provided
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const serverDisplayName = label;
const safeCloseMessage = safeBody;
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
<p><strong>Message:</strong></p>
<p>${safeCloseMessage}</p>
<p style="margin-top: 16px;">${safeCloseSignature}</p>
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 12px;">
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
</td>
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
<div style="color: #666; font-size: 12px;">${safeSignature}</div>
</td>
</tr>
</table>
</div>`;
const rawHeaders = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
msgId ? `In-Reply-To: ${msgId}` : '',
msgId ? `References: ${msgId}` : '',
'MIME-Version: 1.0',
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody
].filter(Boolean);
const raw = Buffer.from(rawHeaders.join('\r\n'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw, threadId: ticket.gmailThreadId }
await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId,
recipient,
encodedSubject,
msgId,
messageText: messageBody,
userId
});
} catch (err) {
console.error('Ticket notification email error:', err);
@@ -234,111 +209,24 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
}
/**
* Send a Gmail reply to a ticket
* @param {string} threadId - Gmail thread ID
* @param {string} replyText - Reply text
* @param {string} recipientEmail - Recipient email
* @param {string} subject - Subject line
* @param {string} discordUser - Discord user name
* @param {string} messageId - Message ID (optional)
* @param {string} userId - Discord user ID for signature (optional)
* Send a Gmail reply on an existing thread. Caller supplies subject + messageId
* (typically pulled from the latest non-self message in the thread).
*/
async function sendGmailReply(
threadId,
replyText,
recipientEmail,
subject,
discordUser,
messageId,
userId = null
) {
const gmail = getGmailClient();
async function sendGmailReply(threadId, replyText, recipientEmail, subject, messageId, userId = null) {
const safeRecipient = sanitizeHeaderValue(extractRawEmail(recipientEmail || '')).toLowerCase();
if (!EMAIL_RE.test(safeRecipient)) {
logError('sendGmailReply: invalid recipient', new Error(`Rejected: ${safeRecipient}`)).catch(() => {});
return null;
}
const safeMessageId = sanitizeHeaderValue(messageId);
const safeSubject = sanitizeHeaderValue(`Re: ${subject}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(
safeSubject
).toString('base64')}?=`;
const safeUser = escapeHtml(discordUser);
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
const companySignatureText = (CONFIG.SIGNATURE || '').replace(/<br>/g, '\n');
// Get staff signature if userId provided
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html must arrive pre-escaped; do not inject raw HTML here.
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = signatureBlocks.text;
const safeCompanySigHtml = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="padding-right: 12px;">
${safeLogoUrl ? `<img src="${safeLogoUrl}" width="65">` : ''}
</td>
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
<p style="margin: 0; font-weight: bold;">${safeUser}</p>
<div style="color: #666; font-size: 12px;">${safeCompanySigHtml}</div>
</td>
</tr>
</table>
</div>`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(replyText);
if (safeStaffSigText) {
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push('------------------------------');
plainBody.push('');
plainBody.push(companySignatureText);
const raw = Buffer.from([
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${safeRecipient}`,
`Subject: ${utf8Subject}`,
safeMessageId ? `In-Reply-To: ${safeMessageId}` : '',
safeMessageId ? `References: ${safeMessageId}` : '',
'MIME-Version: 1.0',
'Content-Type: multipart/alternative; boundary="' + boundary + '"',
'',
'--' + boundary,
'Content-Type: text/plain; charset="UTF-8"',
'',
...plainBody,
'',
'--' + boundary,
'Content-Type: text/html; charset="UTF-8"',
'',
htmlBody,
'',
'--' + boundary + '--'
].join('\r\n'))
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
await gmail.users.messages.send({
userId: 'me',
requestBody: { raw, threadId }
const gmail = getGmailClient();
await sendThreadedEmail(gmail, {
threadId,
recipient: safeRecipient,
encodedSubject: encodeReplySubject(subject || 'Support'),
msgId: sanitizeHeaderValue(messageId) || null,
messageText: replyText,
userId
});
}

View File

@@ -1,33 +0,0 @@
/**
* Guild-specific settings (e.g. email ticket routing).
*/
const { mongoose } = require('../db-connection');
const GuildSettings = mongoose.model('GuildSettings');
/**
* Get email ticket routing for a guild. Returns 'thread' or 'category'.
* If not set, defaults to 'category'.
* @param {string} guildId
* @returns {Promise<'thread'|'category'>}
*/
async function getEmailRouting(guildId) {
const doc = await GuildSettings.findOne({ guildId }).select('emailRouting').lean();
if (doc && doc.emailRouting) return doc.emailRouting;
return 'category';
}
/**
* Set email ticket routing for a guild.
* @param {string} guildId
* @param {'thread'|'category'} value
*/
async function setEmailRouting(guildId, value) {
await GuildSettings.findOneAndUpdate(
{ guildId },
{ $set: { emailRouting: value, updatedAt: new Date() } },
{ upsert: true, new: true }
);
}
module.exports = { getEmailRouting, setEmailRouting };

View File

@@ -31,9 +31,9 @@ async function pinMessage(message, client) {
}
} catch (err) {
if (err.code === 30003) {
await logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
logWarn('pinMessage', `Max pins reached in channel #${message.channel.name} — could not pin message.`, client).catch(() => {});
} else {
await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
}
}
}

View File

@@ -11,9 +11,10 @@
* is logged via logWarn.
* - invitable: false means only staff with MANAGE_THREADS can add additional
* members — this is intentional for privacy.
* - guild.members.fetch() in addRoleMembersToThread can be slow on large
* servers. The 300ms delay between adds avoids the thread member add rate
* limit (approximately 5/second).
* - addRoleMembersToThread reads from role.members (cache-derived) and only
* falls back to a scoped guild.members.fetch on cache miss. The 300ms
* delay between adds avoids the thread member add rate limit (~5/sec).
* It runs via setImmediate so it doesn't block ticket creation.
*/
const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config');
@@ -39,7 +40,11 @@ async function createStaffThread(channel, client) {
});
if (CONFIG.STAFF_THREAD_AUTO_ADD_ROLE && CONFIG.STAFF_THREAD_ROLE_ID) {
await addRoleMembersToThread(thread, channel.guild, client);
// Run off the critical path — the add loop is rate-limited at 300ms per
// member and would block ticket creation for ~15s on a 50-member role.
setImmediate(() => {
addRoleMembersToThread(thread, channel.guild, client).catch(() => {});
});
}
return thread;
@@ -48,30 +53,40 @@ async function createStaffThread(channel, client) {
if (err.code === 50024 || err.code === 160004) {
logWarn('staffThread', `Cannot create private thread in ${channel.name}: server may lack Community features or required boost level (code ${err.code}).`).catch(() => {});
}
await logError('staffThread:create', err, null, client).catch(() => {});
logError('staffThread:create', err, null, client).catch(() => {});
return null;
}
}
/**
* Add all members of the staff role to the thread.
*
* Prefers role.members (computed from guild.members.cache, kept in sync via
* the GuildMembers gateway intent — see broccolini-discord.js intents). Only
* falls back to a scoped guild.members.fetch on cache miss (e.g. cold cache
* just after restart). Previously called the unscoped guild.members.fetch()
* on every ticket creation, which chunked all members of the guild — wasted
* gateway/REST budget and added ~15s to ticket creation on busy guilds.
*/
async function addRoleMembersToThread(thread, guild, client) {
try {
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
if (!role) return;
await guild.members.fetch();
const members = guild.members.cache.filter(m =>
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot
);
let members = role.members.filter(m => !m.user.bot);
if (members.size === 0) {
// Cache cold (first ticket after restart). withPresences: false skips
// the presence sync, which is irrelevant for thread-add and expensive.
await guild.members.fetch({ withPresences: false }).catch(() => {});
members = role.members.filter(m => !m.user.bot);
}
for (const [, member] of members) {
await thread.members.add(member.id).catch(() => {});
await new Promise(r => setTimeout(r, 300));
}
} catch (err) {
await logError('staffThread:addMembers', err, null, client).catch(() => {});
logError('staffThread:addMembers', err, null, client).catch(() => {});
}
}

View File

@@ -2,11 +2,9 @@
* Ticket database helpers counters, rename, limits, auto-close,
* reminders, auto-unclaim, channel creation.
*/
const { ChannelType, PermissionFlagsBits } = require('discord.js');
const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog');
const { enqueueSend, enqueueDelete } = require('./channelQueue');
const Ticket = mongoose.model('Ticket');
@@ -30,9 +28,6 @@ async function getNextTicketNumber(senderEmail) {
// primary bot's 2/10min per-channel budget here; 429s from the secondary
// bot surface via utils/renamer.js instead.
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes (unused; kept for back-compat)
const RENAME_LIMIT = 2;
function getSenderLocal(senderEmail) {
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
}
@@ -56,7 +51,12 @@ function toDiscordSafeName(str) {
*/
async function resolveCreatorNickname(guild, ticket) {
if (ticket.gmailThreadId.startsWith('discord-')) {
const creatorUserId = ticket.gmailThreadId.split('-').pop();
// Prefer ticket.creatorId (stored on creation). Legacy fallback parses the
// tail segment, which is correct for discord-${ts}-${userId} but returns
// the message ID for discord-msg-${ts}-${msgId} — skip the parse for those.
const creatorUserId = ticket.creatorId
|| (ticket.gmailThreadId.startsWith('discord-msg-') ? null : ticket.gmailThreadId.split('-').pop());
if (!creatorUserId) return getSenderLocal(ticket.senderEmail);
try {
const member = await guild.members.fetch(creatorUserId);
return member.displayName;
@@ -90,16 +90,6 @@ function makeTicketName(state, ticket, creatorNickname, claimerEmoji) {
}
}
// Retained for external callers (bOSScord, scripts). The gate now lives in
// the secondary bot's rate bucket; this helper no longer touches Mongo.
async function canRename(_ticket) {
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
}
function minutesFromMs(ms) {
return Math.max(1, Math.ceil(ms / 60000));
}
// --- RATE LIMIT (per-user ticket creation) ---
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
@@ -156,15 +146,6 @@ function escapeCategoryNameForRegex(name) {
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* @deprecated Use getOrCreateTicketCategory instead.
* @returns {null}
*/
function pickTicketCategoryId(guild, categoryIds) {
console.warn('[tickets] pickTicketCategoryId is deprecated; use getOrCreateTicketCategory() instead');
return null;
}
function countChannelsInCategory(guild, categoryId) {
return guild.channels.cache.filter(c => c.parentId === categoryId).size;
}
@@ -272,148 +253,6 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
}
}
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
if (!parentChannel) {
throw new Error('Thread parent channel not found');
}
const thread = await parentChannel.threads.create({
name: `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
await thread.members.add(userId);
// Add all members with the support role so they can see and reply in the thread
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
if (memberId === userId) continue; // already added
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
} else {
let parentId;
try {
parentId = await getOrCreateTicketCategory(guild, CONFIG.TICKET_CATEGORY_ID, CONFIG.TICKET_CATEGORY_NAME);
} catch (e) {
console.error('getOrCreateTicketCategory (createTicketChannel):', e);
throw new Error('Ticket category not found or could not be allocated');
}
let channel;
try {
channel = await guild.channels.create({
name: creatorNickname ? toDiscordSafeName(`unclaimed-${creatorNickname}-${ticketNumber}`) : `ticket-${ticketNumber}`,
type: ChannelType.GuildText,
parent: parentId,
permissionOverwrites: [
{
id: guild.id,
deny: [PermissionFlagsBits.ViewChannel]
},
{
id: userId,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
},
{
id: CONFIG.ROLE_ID_TO_PING,
allow: [
PermissionFlagsBits.ViewChannel,
PermissionFlagsBits.SendMessages,
PermissionFlagsBits.ReadMessageHistory
]
}
]
});
} catch (e) {
console.error('guild.channels.create (createTicketChannel):', e);
throw e;
}
return channel;
}
}
/**
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
* Adds creator and all members with ROLE_ID_TO_PING.
* @param {import('discord.js').Guild} guild
* @param {number} ticketNumber
* @param {string} creatorUserId
* @returns {Promise<import('discord.js').ThreadChannel>}
*/
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
const parentChannel = guild.channels.cache.get(parentId);
if (!parentChannel) throw new Error('Discord thread parent channel not found');
const thread = await parentChannel.threads.create({
name: `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
await thread.members.add(creatorUserId);
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
if (memberId === creatorUserId) continue;
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
}
/**
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
* @param {import('discord.js').Guild} guild
* @param {number} ticketNumber
* @param {string} chanName
* @returns {Promise<import('discord.js').ThreadChannel>}
*/
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
const parentChannel = guild.channels.cache.get(parentId);
if (!parentChannel) throw new Error('Email thread parent channel not found');
const thread = await parentChannel.threads.create({
name: chanName || `🎫・ticket-${ticketNumber}`,
autoArchiveDuration: 1440,
type: ChannelType.PrivateThread,
invitable: false,
reason: `Ticket #${ticketNumber}`
});
if (CONFIG.ROLE_ID_TO_PING) {
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
if (role?.members?.size) {
for (const [memberId] of role.members) {
await thread.members.add(memberId).catch(() => {});
}
}
}
return thread;
}
// --- LIMITS & PERMISSIONS ---
async function checkTicketLimits(senderEmail) {
@@ -430,22 +269,12 @@ async function checkTicketLimits(senderEmail) {
return { ok: true };
}
function hasBlacklistedRole(member) {
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
return false;
}
return member.roles.cache.some(role =>
CONFIG.BLACKLISTED_ROLES.includes(role.id)
);
}
// --- ACTIVITY ---
async function updateTicketActivity(gmailThreadId) {
const now = new Date();
await Ticket.updateOne(
{ gmailThreadId },
{ $set: { lastActivity: now, reminderSent: false } }
{ $set: { lastActivity: new Date() } }
);
}
@@ -462,9 +291,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean());
let checked = 0, closed = 0;
for (const ticket of staleTickets) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
@@ -481,63 +308,23 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
{ $set: { status: 'closed', pendingDelete: true } }
));
await sendTicketClosedEmail(ticket, 'Auto-Close System');
await sendTicketClosedEmail(ticket, 'Auto-Close System', null);
setTimeout(() => {
// Lazy require — broccolini-discord re-exports trackTimeout; cycle-safe.
const { trackTimeout } = require('../broccolini-discord');
trackTimeout(setTimeout(() => {
enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } }
)).catch(() => {});
}).catch(() => {});
}, 5000);
closed++;
}, 5000));
}
} catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
}
}
logAutomation('Auto-close run', null, `checked: ${checked}, closed: ${closed}`).catch(() => {});
}
async function checkReminders(client) {
if (!CONFIG.REMINDER_ENABLED) return;
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
const ticketsNeedingReminder = await withRetry(() => Ticket.find({
status: 'open',
lastActivity: { $lt: reminderTime, $ne: null },
reminderSent: false
}).lean());
let checked = 0, reminded = 0;
for (const ticket of ticketsNeedingReminder) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
const ping = ticket.claimedBy
? `<@${ticket.claimedBy}>`
: (CONFIG.ROLE_ID_TO_PING ? `<@&${CONFIG.ROLE_ID_TO_PING}>` : 'everyone');
const message = CONFIG.REMINDER_MESSAGE
.replace(/\{hours\}/g, String(CONFIG.REMINDER_AFTER_HOURS))
.replace(/\{ping\}/g, ping);
await enqueueSend(channel, message);
await withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { reminderSent: true } }
));
reminded++;
}
} catch (error) {
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
}
}
logAutomation('Reminder run', null, `checked: ${checked}, reminded: ${reminded}`).catch(() => {});
}
async function checkAutoUnclaim(client) {
@@ -550,9 +337,7 @@ async function checkAutoUnclaim(client) {
lastActivity: { $lt: unclaimTime, $ne: null }
}).lean());
let checked = 0, unclaimed = 0;
for (const ticket of staleClaimedTickets) {
checked++;
try {
const guild = client.guilds.cache.first();
if (!guild) continue;
@@ -569,18 +354,16 @@ async function checkAutoUnclaim(client) {
);
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
unclaimed++;
}
} catch (error) {
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
}
}
logAutomation('Auto-unclaim run', null, `checked: ${checked}, unclaimed: ${unclaimed}`).catch(() => {});
}
async function reconcileDeletedTicketChannels(client) {
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first();
if (!guild) return { checked: 0, reconciled: 0 };
if (!guild) return;
// Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({
@@ -588,9 +371,7 @@ async function reconcileDeletedTicketChannels(client) {
discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean();
let checked = 0, reconciled = 0;
for (const ticket of openTickets) {
checked++;
try {
let channel = guild.channels.cache.get(ticket.discordThreadId);
if (!channel) {
@@ -601,17 +382,11 @@ async function reconcileDeletedTicketChannels(client) {
{ gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', discordThreadId: null } }
);
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
reconciled++;
}
} catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
}
}
if (reconciled > 0) {
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
}
return { checked, reconciled };
}
/**
@@ -621,8 +396,7 @@ async function reconcileDeletedTicketChannels(client) {
*/
async function resumePendingDeletes(client) {
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
if (!pending.length) return 0;
let resumed = 0;
if (!pending.length) return;
for (const ticket of pending) {
try {
const guild = client.guilds.cache.first();
@@ -630,7 +404,6 @@ async function resumePendingDeletes(client) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) {
enqueueDelete(channel).catch(() => {});
resumed++;
}
}
Ticket.updateOne(
@@ -641,35 +414,22 @@ async function resumePendingDeletes(client) {
console.error('resumePendingDeletes error:', e);
}
}
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
return resumed;
}
module.exports = {
getNextTicketNumber,
getOrCreateTicketCategory,
cleanupEmptyOverflowCategory,
createDiscordTicketAsThread,
createEmailTicketAsThread,
RENAME_WINDOW_MS,
RENAME_LIMIT,
getSenderLocal,
toDiscordSafeName,
resolveCreatorNickname,
makeTicketName,
canRename,
minutesFromMs,
checkTicketCreationRateLimit,
createTicketChannel,
checkTicketLimits,
hasBlacklistedRole,
updateTicketActivity,
checkAutoClose,
checkReminders,
checkAutoUnclaim,
reconcileDeletedTicketChannels,
resumePendingDeletes,
startTicketsSweeps,
sweepTicketCreationByUser,
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
startTicketsSweeps
};

View File

@@ -1,76 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Scope
This is the **settings-site** subdirectory of the broccolini-bot repo. It is a **separate Express process** that provides an admin web UI for editing the bot's runtime config. It is **not** part of the bot's Node process.
The parent repo's rules in `/opt/broccolini-bot/CLAUDE.md` still apply here — especially **CommonJS only**, **read before write**, and **no unsolicited refactors**. Read that file alongside this one.
## Commands
- `npm start` — run the settings site (`node server.js`).
- `npm run dev` — run with `node --watch` for auto-reload.
- No lint, no test framework, no build step. Frontend is vanilla JS served from `public/` — no bundler.
- Deploy via its own compose file: `docker compose up --build -d` from this directory. Container name `broccolini-settings`, joins external `broccoli-net`.
## Architecture
### Two processes, one `.env`
The settings site is a thin HTTPS-oriented proxy in front of the bot's internal API:
```
browser ──► settings server.js (:SETTINGS_PORT, default 12752)
│ session auth (SETTINGS_ADMIN_PASSWORD)
bot internalApp (broccoli-net only, INTERNAL_API_PORT, default 12753)
│ header auth (x-internal-secret = INTERNAL_API_SECRET)
routes/internalApi.js in /opt/broccolini-bot
```
`server.js` loads `../.env` (the **bot's** env file) — both processes share it. `docker-compose.yml` also mounts `env_file: ../.env`, not a local one. There is no settings-site-specific env beyond what's in `.env.example`.
### Proxied endpoints
`server.js` exposes five authenticated endpoints that forward to the bot's `/internal/*` API via `callBot()`:
| Settings route | Bot route |
|---|---|
| `GET /api/config` | `GET /internal/config` |
| `POST /api/config` | `POST /internal/config` |
| `GET /api/discord/guild` | `GET /internal/discord/guild` |
| `POST /api/restart` | `POST /internal/restart` |
| `GET /api/restart/status` | `GET /internal/restart/status` |
| `GET /api/notifications/alerts` | `GET /internal/notifications/alerts` |
| `GET /api/notifications/state` | `GET /internal/notifications/state` |
| `POST /api/notifications/toggle` | `POST /internal/notifications/toggle` |
Every response-shape change in the bot's `/internal/*` handlers (`routes/internalApi.js`) is a breaking change here. The bot also gates `POST /internal/config` on an `ALLOWED_CONFIG_KEYS` allowlist — **adding a new field to the UI requires adding the key to that Set in the bot first**, otherwise the save returns 400 for that key.
### Second admin password
`server.js` accepts an optional `SETTINGS_ADMIN_PASSWORD_2`. If set, the `/login` handler grants the same session for either password; no audit distinction between them. The primary `SETTINGS_ADMIN_PASSWORD` is still required at startup — only the second is optional. Both are redacted by the bot's `GET /internal/config` response.
### Session cookie requires HTTPS
`server.js:20-26` sets `cookie.secure: true`. Browsers will refuse to persist the session cookie over plain HTTP, so login silently fails when not behind an HTTPS reverse proxy (`SETTINGS_DOMAIN` is the deployed domain). If you're reproducing a login bug, check this first before debugging auth logic. The `session secret` falls back to `'fallback-secret-change-me'` when `INTERNAL_API_SECRET` is unset — don't rely on the fallback in any environment that matters.
### Client-side routing
`public/js/` is split into focused modules (phase 4 refactor): `app.js` (bootstrap), `router.js`, `fields.js`, `notifications.js`, `discord.js`, `login.js`, `util.js` — no bundler, loaded via `<script>` tags. Routes live in the `ROUTES` map (`router.js:4`); the server has a catch-all back to `index.html` (`server.js:202`, Express 5 `'/*splat'` syntax), so adding a client route only requires editing `ROUTES`.
### Config field binding (frontend)
Any form element with `data-key="SOME_CONFIG_KEY"` participates in the editor:
- `populateFields()` (`fields.js:11`) fills it from `GET /api/config` and wires change listeners.
- Checkboxes serialize to the strings `'true'` / `'false'`, and `<input type="color">` serializes to `0xRRGGBB` — this matches how the bot stores these values.
- `pendingChanges` accumulates diffs; `saveConfig()` POSTs the whole diff at once.
- `data-smart="channel|category|role|member|multi-member"` swaps the bare `<input>` for a searchable Discord picker backed by `GET /api/discord/guild` (see `public/js/discord.js`).
**To add a new editable config field:** (1) add the key to the bot's `ALLOWED_CONFIG_KEYS`, (2) add a `<input data-key="NEW_KEY">` (optionally `data-smart=…`) inside the appropriate `.section` in `public/index.html`. No JS changes needed.
### Notification thresholds editor
The Notifications section is **not** a simple `data-key` field — it's a custom editor in `notifications.js` that serializes into a single hidden `NOTIFICATION_THRESHOLDS_JSON` field. Alert metadata is now a **dynamic registry** (phase 5): the bot is canonical and serves it via `GET /api/notifications/alerts`; `notifications.js` uses `FALLBACK_TAB_KEYS` only if the fetch fails. **To add a new alert key, register it in the bot** (not in this codebase) — the UI picks it up automatically on next load. Threshold values accept whole numbers or duration strings matching `^(\d+[mhd])+$` (e.g. `15m`, `1h`, `1d6h`).
## Gotchas
- The frontend has no framework and no build — edit `public/js/*.js` directly; changes are live on reload.
- `getaddrinfo` failures from `callBot()` surface to the UI as "Bot unreachable" (502). This is almost always the bot process being down or the internal port being wrong, not a bug in this codebase.
- `docker-compose.yml` binds the port to the Tailscale IP `100.114.205.53:12752` — not `0.0.0.0`. Changing that binding has security implications.

View File

@@ -649,164 +649,6 @@ body::before {
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Notifications section */
#s-notifications .notif-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 22px;
border-bottom: 1px solid var(--border);
}
#s-notifications .notif-tab-btn {
border: none;
background: transparent;
color: var(--text-muted);
font-family: var(--font-title);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
padding: 10px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 160ms ease, border-color 160ms ease;
}
#s-notifications .notif-tab-btn:hover { color: var(--text); }
#s-notifications .notif-tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
#s-notifications .notif-panel.hidden { display: none; }
#s-notifications .notif-editor {
border: 1px solid var(--border);
padding: 20px;
margin-bottom: 16px;
background: var(--surface-2);
}
#s-notifications .notif-chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 14px 0;
min-height: 32px;
}
#s-notifications .notif-chip {
display: inline-flex;
align-items: center;
gap: 10px;
border: 1px solid var(--primary);
background: var(--primary-dim);
color: var(--primary);
padding: 5px 12px;
font-family: var(--font-title);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
#s-notifications .notif-chip button {
border: none;
background: transparent;
color: currentColor;
cursor: pointer;
padding: 0;
line-height: 1;
font-size: 14px;
opacity: 0.6;
}
#s-notifications .notif-chip button:hover { opacity: 1; }
#s-notifications .notif-input-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
#s-notifications .notif-input-row input { width: 220px; }
#s-notifications .notif-presets {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 14px;
}
#s-notifications .notif-presets button,
#s-notifications .notif-add-btn {
padding: 8px 14px;
border: 1px solid var(--border-strong);
background: transparent;
color: var(--text-muted);
font-family: var(--font-title);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
}
#s-notifications .notif-presets button:hover,
#s-notifications .notif-add-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-dim-2);
}
#s-notifications .notif-trigger { margin-top: 16px; }
#s-notifications .notif-trigger summary {
cursor: pointer;
color: var(--text-muted);
font-family: var(--font-title);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
margin-bottom: 14px;
user-select: none;
list-style: none;
display: inline-flex;
align-items: center;
gap: 8px;
}
#s-notifications .notif-trigger summary::-webkit-details-marker { display: none; }
#s-notifications .notif-trigger summary::before {
content: '+';
color: var(--primary);
font-weight: 700;
font-size: 14px;
}
#s-notifications .notif-trigger[open] summary::before { content: ''; }
#s-notifications .notif-trigger[open] summary { color: var(--primary); }
/* Phase 9 — notification enable toggles */
#s-notifications .notif-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid var(--border);
}
#s-notifications .notif-toggle-group {
display: flex;
align-items: center;
gap: 10px;
}
#s-notifications .notif-toggle-label {
font-family: var(--font-title);
font-size: 13px;
font-weight: 700;
color: var(--text);
letter-spacing: 0;
}
#s-notifications .notif-per-alert-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.notif-disabled {
opacity: 0.5;
pointer-events: none;
user-select: none;
}
/* Logging hint link */
.logging-hint { color: var(--text-muted); font-size: 13px; }
.logging-hint a {
@@ -923,12 +765,6 @@ body::before {
.sidebar a { padding: 14px 20px; min-height: 44px; font-size: 12px; }
.section-header { padding: 18px 20px; }
.smart-select-display { min-height: 44px; }
#s-notifications .notif-chip { padding: 8px 12px; }
#s-notifications .notif-chip button { min-width: 28px; min-height: 28px; font-size: 18px; }
#s-notifications .notif-tab-btn,
#s-notifications .notif-add-btn,
#s-notifications .notif-presets button { min-height: 40px; padding: 10px 14px; }
#s-notifications .notif-input-row input { flex: 1 1 auto; width: auto; min-width: 0; }
.modal-card { width: calc(100vw - 32px); min-width: 0; max-width: 420px; }

View File

@@ -22,7 +22,6 @@
<a href="/behavior">Ticket Behavior</a>
<a href="/threads">Staff Threads</a>
<a href="/pins">Pin Messages</a>
<a href="/notifications">Notifications</a>
<a href="/logging">Logging</a>
<a href="/automation">Automation</a>
<a href="/appearance">Appearance</a>
@@ -58,7 +57,6 @@
<a class="landing-card" href="/behavior"><div class="landing-card-body"><h3>Ticket Behavior</h3><p>Automation, limits, and messages</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/threads"><div class="landing-card-body"><h3>Staff Threads</h3><p>Private staff discussion threads</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/pins"><div class="landing-card-body"><h3>Pin Messages</h3><p>Auto-pin welcome and escalations</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/notifications"><div class="landing-card-body"><h3>Notifications</h3><p>Surge, patterns, unclaimed, chat</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/logging"><div class="landing-card-body"><h3>Logging</h3><p>Log channel configuration</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/automation"><div class="landing-card-body"><h3>Automation</h3><p>Polling intervals and timers</p></div><span class="chevron">&#8250;</span></a>
<a class="landing-card" href="/appearance"><div class="landing-card-body"><h3>Appearance</h3><p>Colors, labels, emojis</p></div><span class="chevron">&#8250;</span></a>
@@ -84,21 +82,11 @@
<div class="field"><label>Transcript Channel</label><input type="text" data-key="TRANSCRIPT_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Logging Channel</label><input type="text" data-key="LOGGING_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>Backup/Export Channel</label><input type="text" data-key="BACKUP_EXPORT_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Account Info Channel</label><input type="text" data-key="ACCOUNT_INFO_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>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>System Log Channel</label><input type="text" data-key="SYSTEM_LOG_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>All Staff Channel</label><input type="text" data-key="ALL_STAFF_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Chat Alert Channel</label><input type="text" data-key="ALL_STAFF_CHAT_ALERT_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>User Patterns Channel</label><input type="text" data-key="USER_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Game Patterns Channel</label><input type="text" data-key="GAME_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Tag Patterns Channel</label><input type="text" data-key="TAG_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Escalation Patterns Channel</label><input type="text" data-key="ESCALATION_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Staff Patterns Channel</label><input type="text" data-key="STAFF_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Combined Patterns Channel</label><input type="text" data-key="COMBINED_PATTERNS_CHANNEL_ID" data-smart="channel"></div>
</div></div>
</div>
@@ -112,12 +100,9 @@
<div class="field"><label>Discord T2 Category</label><input type="text" data-key="DISCORD_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
<div class="field"><label>Email T3 Category</label><input type="text" data-key="EMAIL_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
<div class="field"><label>Discord T3 Category</label><input type="text" data-key="DISCORD_ESCALATED3_CHANNEL_ID" data-smart="category"></div>
<div class="field"><label>Staff Notification Category</label><input type="text" data-key="STAFF_NOTIFICATION_CATEGORY_ID" data-smart="category"></div>
<div class="field"><label>Category Name</label><input type="text" data-key="TICKET_CATEGORY_NAME"></div>
<div class="field"><label>T2 Category Name</label><input type="text" data-key="TICKET_T2_CATEGORY_NAME"></div>
<div class="field"><label>T3 Category Name</label><input type="text" data-key="TICKET_T3_CATEGORY_NAME"></div>
<div class="field"><label>Discord Thread Channel</label><input type="text" data-key="DISCORD_THREAD_CHANNEL_ID" data-smart="channel"></div>
<div class="field"><label>Email Thread Channel</label><input type="text" data-key="EMAIL_THREAD_CHANNEL_ID" data-smart="channel"></div>
</div></div>
</div>
@@ -140,9 +125,6 @@
<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>Priority System</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PRIORITY_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Claim Timeout</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="CLAIM_TIMEOUT_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Claim Timeout Hours</label><input type="number" data-key="CLAIM_TIMEOUT_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 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>
@@ -179,216 +161,6 @@
</div></div>
</div>
<!-- 8. Notifications -->
<div class="section" id="s-notifications">
<div class="section-header"><h2>Notifications</h2><p>Threshold milestones and trigger conditions by alert category</p><span class="chevron">&#9660;</span></div>
<div class="section-body">
<input type="hidden" data-key="NOTIFICATION_THRESHOLDS_JSON">
<div class="notif-tabs" role="tablist" aria-label="Notification categories">
<button type="button" class="notif-tab-btn active" data-notif-tab="surge">Surge</button>
<button type="button" class="notif-tab-btn" data-notif-tab="patterns">Patterns</button>
<button type="button" class="notif-tab-btn" data-notif-tab="unclaimed">Unclaimed</button>
<button type="button" class="notif-tab-btn" data-notif-tab="chat">Chat</button>
</div>
<div class="notif-panel" data-notif-panel="surge">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="surge">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Surge alerts fire when active ticket conditions cross thresholds — high volume, unclaimed queues, no staff online. Each alert escalates through its threshold list, spacing out pings as the condition persists. The counter resets when the condition clears.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="surge"></select></div>
<div class="hint notif-alert-description" data-notif-description="surge"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="surge"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="surge" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="surge">Add</button>
</div>
<div class="notif-presets" data-notif-presets="surge"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field"><label>Surge Role</label><input type="text" data-key="SURGE_ROLE_ID" data-smart="role"></div>
<div class="field"><label>Ticket Surge Count</label><input type="number" data-key="SURGE_TICKET_COUNT"></div>
<div class="field"><label>Ticket Window (min)</label><input type="number" data-key="SURGE_TICKET_WINDOW_MINUTES"></div>
<div class="field"><label>Game Surge Count</label><input type="number" data-key="SURGE_GAME_TICKET_COUNT"></div>
<div class="field"><label>Game Window (min)</label><input type="number" data-key="SURGE_GAME_TICKET_WINDOW_MINUTES"></div>
<div class="field"><label>Stale Count</label><input type="number" data-key="SURGE_STALE_COUNT"></div>
<div class="field"><label>Stale Hours</label><input type="number" data-key="SURGE_STALE_HOURS"></div>
<div class="field"><label>Needs Response Count</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_COUNT"></div>
<div class="field"><label>Needs Response Hours</label><input type="number" data-key="SURGE_NEEDS_RESPONSE_HOURS"></div>
<div class="field"><label>Unclaimed Count</label><input type="number" data-key="SURGE_UNCLAIMED_COUNT"></div>
<div class="field"><label>Unclaimed Minutes</label><input type="number" data-key="SURGE_UNCLAIMED_MINUTES"></div>
<div class="field"><label>Tier 3 Unclaimed (min)</label><input type="number" data-key="SURGE_TIER3_UNCLAIMED_MINUTES"></div>
<div class="field"><label>Cooldown (min)</label><input type="number" data-key="SURGE_COOLDOWN_MINUTES"></div>
<div class="field"><label>DND = Available</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_DND_COUNTS_AS_AVAILABLE"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>No-Staff Cooldown (min)</label><input type="number" data-key="SURGE_NO_STAFF_COOLDOWN_MINUTES"></div>
<div class="field"><label>No-Staff Ticket Threshold</label><input type="number" data-key="SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD"></div>
</div>
</details>
</div>
<div class="notif-panel hidden" data-notif-panel="patterns">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="patterns">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Pattern alerts detect trends over time — surges by game, escalation rates, staff behavior. Each alert fires once per threshold crossed within its window (daily/weekly/monthly) and won't repeat until the next window resets.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="patterns"></select></div>
<div class="hint notif-alert-description" data-notif-description="patterns"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="patterns"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="patterns" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="patterns">Add</button>
</div>
<div class="notif-presets" data-notif-presets="patterns"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field"><label>Check Interval (min)</label><input type="number" data-key="PATTERN_CHECK_INTERVAL_MINUTES"></div>
<div class="field"><label>User Ticket Threshold</label><input type="number" data-key="PATTERN_USER_TICKET_THRESHOLD"></div>
<div class="field"><label>Game Ticket Threshold</label><input type="number" data-key="PATTERN_GAME_TICKET_THRESHOLD"></div>
<div class="field"><label>Staff Stale Ping Threshold</label><input type="number" data-key="PATTERN_STAFF_STALE_PING_THRESHOLD"></div>
<div class="field"><label>Escalation Threshold</label><input type="number" data-key="PATTERN_ESCALATION_THRESHOLD"></div>
<div class="field"><label>Rapid Close Seconds</label><input type="number" data-key="PATTERN_RAPID_CLOSE_SECONDS"></div>
<div class="field"><label>Unclaimed Hours</label><input type="number" data-key="PATTERN_UNCLAIMED_HOURS"></div>
</div>
</details>
</div>
<div class="notif-panel hidden" data-notif-panel="unclaimed">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="unclaimed">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Per-ticket reminders sent to staff notification channels when a ticket remains unclaimed. Each threshold fires once per ticket. Escalating a ticket resets the threshold list so reminders restart for the new tier.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="unclaimed"></select></div>
<div class="hint notif-alert-description" data-notif-description="unclaimed"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="unclaimed"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="unclaimed" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="unclaimed">Add</button>
</div>
<div class="notif-presets" data-notif-presets="unclaimed"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field full-width"><p class="hint">Unclaimed notifications use threshold milestones only.</p></div>
</div>
</details>
</div>
<div class="notif-panel hidden" data-notif-panel="chat">
<div class="notif-toggle-row">
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-master>
<span class="slider"></span>
</label>
<span class="notif-toggle-label">Master (all categories)</span>
</div>
<div class="notif-toggle-group">
<label class="toggle">
<input type="checkbox" data-notif-category-toggle="chat">
<span class="slider"></span>
</label>
<span class="notif-toggle-label">All in category</span>
</div>
</div>
<p class="hint">Monitors configured chat channels for unresponded user messages. Fires at escalating intervals while the condition persists. Resets when a staff member responds.</p>
<div class="notif-editor">
<div class="field"><label>Alert key</label><select class="notif-alert-select" data-notif-category="chat"></select></div>
<div class="hint notif-alert-description" data-notif-description="chat"></div>
<div class="notif-per-alert-row">
<label class="toggle">
<input type="checkbox" data-notif-alert>
<span class="slider"></span>
</label>
<span class="notif-toggle-label" data-notif-alert-label>Alert disabled</span>
</div>
<div class="notif-chips" data-notif-chips="chat"></div>
<div class="notif-input-row">
<input type="text" class="notif-threshold-input" data-notif-input="chat" placeholder="15m, 1h, 1d6h, 2d6h, 5">
<button type="button" class="notif-add-btn" data-notif-add="chat">Add</button>
</div>
<div class="notif-presets" data-notif-presets="chat"></div>
</div>
<details class="notif-trigger">
<summary>Trigger conditions</summary>
<div class="field-grid">
<div class="field"><label>Chat Alert Message Count</label><input type="number" data-key="CHAT_ALERT_MESSAGE_COUNT"></div>
<div class="field"><label>Chat No-Response Hours</label><input type="number" data-key="CHAT_ALERT_HOURS_WITHOUT_RESPONSE"></div>
<div class="field"><label>Chat Alert Cooldown (min)</label><input type="number" data-key="CHAT_ALERT_COOLDOWN_MINUTES"></div>
</div>
</details>
</div>
</div>
</div>
<!-- 10. Logging -->
<div class="section" id="s-logging">
<div class="section-header"><h2>Logging</h2><p>Log channel configuration (channels set in Channels section)</p><span class="chevron">&#9660;</span></div>
@@ -429,23 +201,16 @@
<div class="field"><label>Close Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLOSE"></div>
<div class="field"><label>Claim Emoji</label><input type="text" data-key="BUTTON_EMOJI_CLAIM"></div>
<div class="field"><label>Unclaim Emoji</label><input type="text" data-key="BUTTON_EMOJI_UNCLAIM"></div>
<div class="field"><label>High Priority Emoji</label><input type="text" data-key="PRIORITY_HIGH_EMOJI"></div>
<div class="field"><label>Medium Priority Emoji</label><input type="text" data-key="PRIORITY_MEDIUM_EMOJI"></div>
<div class="field"><label>Low Priority Emoji</label><input type="text" data-key="PRIORITY_LOW_EMOJI"></div>
<div class="field"><label>Claimer Emoji Fallback</label><input type="text" data-key="CLAIMER_EMOJI_FALLBACK"></div>
</div></div>
</div>
<!-- 13. Staff -->
<div class="section" id="s-staff">
<div class="section-header"><h2>Staff</h2><p>Staff IDs, emojis, and admin settings</p><span class="chevron">&#9660;</span></div>
<div class="section-header"><h2>Staff</h2><p>Admin and staff role settings</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field full-width"><label>Staff IDs (comma-separated)</label><input type="text" data-key="STAFF_IDS" data-smart="multi-member"></div>
<div class="field"><label>Admin ID</label><input type="text" data-key="ADMIN_ID" data-smart="member"></div>
<div class="field full-width"><label>Staff Emojis (userId:emoji, comma-separated)</label><input type="text" data-key="STAFF_EMOJIS"><div class="hint">Format: 123456:emoji,789012:emoji</div></div>
<div class="field full-width"><label>Additional Staff Roles (comma-separated)</label><input type="text" data-key="ADDITIONAL_STAFF_ROLES"><div class="hint">Role IDs with staff permissions</div></div>
<div class="field full-width"><label>Blacklisted Roles (comma-separated)</label><input type="text" data-key="BLACKLISTED_ROLES"><div class="hint">Role IDs that cannot open tickets</div></div>
<div class="field full-width"><label>Unclaimed Reminder Thresholds (hours, comma-separated)</label><input type="text" data-key="UNCLAIMED_REMINDER_THRESHOLDS"><div class="hint">e.g. 1,2,4</div></div>
</div></div>
</div>
@@ -497,7 +262,6 @@
<script defer src="/js/util.js"></script>
<script defer src="/js/router.js"></script>
<script defer src="/js/fields.js"></script>
<script defer src="/js/notifications.js"></script>
<script defer src="/js/discord.js"></script>
<script defer src="/js/app.js"></script>
</body>

View File

@@ -13,7 +13,6 @@
document.getElementById('bot-status-dot').className = 'dot online';
document.getElementById('bot-status-text').textContent = 'Connected';
Fields.populateFields(config);
Notifications.initNotificationsEditor(config);
Fields.initSmartSelects(config);
} catch (e) {
document.getElementById('bot-status-dot').className = 'dot offline';

View File

@@ -1,521 +0,0 @@
(function () {
'use strict';
const NOTIFICATION_PRESETS = ['15m', '30m', '1h', '2h', '4h', '8h', '1d'];
const FALLBACK_TAB_KEYS = {
surge: [
'surge_tickets',
'surge_game',
'surge_stale',
'surge_needs_response',
'surge_unclaimed',
'surge_tier3_unclaimed',
'surge_no_staff'
],
patterns: [
'user_tickets',
'user_reopen',
'user_crossgame',
'game_surge',
'game_backlog',
'game_resolution',
'game_spike',
'tag_top',
'tag_escalation',
'untagged_closes',
'tag_game_corr',
'user_esc',
'game_esc_rate',
'rapid_t2_t3',
'staff_no_close',
'staff_overloaded',
'staff_stale',
'staff_transfer_rate',
'staff_esc',
'staff_game_esc',
'game_tag_spike',
'overnight_gap',
'staff_always_esc'
],
unclaimed: ['unclaimed_reminder'],
chat: ['chat_messages', 'chat_time']
};
const FALLBACK_ALERT_DESCRIPTIONS = {
surge_tickets: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
surge_game: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
surge_stale: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
surge_needs_response: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
surge_unclaimed: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
surge_tier3_unclaimed: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
surge_no_staff: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
user_tickets: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
user_reopen: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
user_crossgame: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
game_surge: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
game_backlog: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
game_resolution: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
game_spike: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
tag_top: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
tag_escalation: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
untagged_closes: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
tag_game_corr: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
user_esc: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
game_esc_rate: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
rapid_t2_t3: 'Fires at ticket count milestones (e.g. 3, 5, 10) when tickets have reached Tier 3 this week. Each milestone fires once per week.',
staff_no_close: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
staff_overloaded: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
staff_stale: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
staff_transfer_rate: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
staff_esc: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
staff_game_esc: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
game_tag_spike: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
overnight_gap: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
staff_always_esc: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
unclaimed_reminder: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
chat_messages: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
chat_time: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.'
};
let notificationThresholdsState = {};
// Phase 9: notification enable state. Fetched from /api/notifications/state
// on init. `master` is the global kill switch; `perKey` is a flat map of
// alertKey → boolean. Both default to off so a fresh deploy is silent.
let enableState = { master: false, perKey: {} };
// Active sources. Start as fallback; replaced/merged when the bot-side
// registry (GET /api/notifications/alerts) returns successfully. On 404 or
// network failure the fallbacks remain authoritative.
let activeTabKeys = FALLBACK_TAB_KEYS;
let activeAlertDescriptions = FALLBACK_ALERT_DESCRIPTIONS;
async function fetchAlertRegistry() {
try {
const res = await fetch('/api/notifications/alerts', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
// Accept only if at least one known category is a non-empty array
const hasShape = ['surge', 'patterns', 'unclaimed'].some(
cat => Array.isArray(data[cat]) && data[cat].length > 0
);
return hasShape ? data : null;
} catch (_) {
return null;
}
}
async function fetchEnableState() {
try {
const res = await fetch('/api/notifications/state', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
if (typeof data.master !== 'boolean') return null;
if (!data.perKey || typeof data.perKey !== 'object') return null;
return { master: data.master, perKey: { ...data.perKey } };
} catch (_) {
return null;
}
}
async function postToggle(body) {
const res = await fetch('/api/notifications/toggle', {
method: 'POST',
credentials: 'same-origin',
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`toggle ${res.status}`);
const data = await res.json();
if (!data || !data.state || typeof data.state.master !== 'boolean' || !data.state.perKey) {
throw new Error('bad toggle response');
}
return { master: data.state.master, perKey: { ...data.state.perKey } };
}
// Merge bot registry with fallback, preserving fallback order for existing
// keys (so rapid_t2_t3 and chat keys stay where the UI expects them).
// Registry-only keys get appended to their category; registry descriptions
// override fallback text.
function mergeRegistryWithFallback(registry) {
const tabKeys = {};
const alertDescriptions = { ...FALLBACK_ALERT_DESCRIPTIONS };
Object.keys(FALLBACK_TAB_KEYS).forEach(cat => { tabKeys[cat] = [...FALLBACK_TAB_KEYS[cat]]; });
Object.entries(registry).forEach(([category, entries]) => {
if (!Array.isArray(entries)) return;
if (!tabKeys[category]) tabKeys[category] = [];
const seen = new Set(tabKeys[category]);
for (const e of entries) {
if (!e || typeof e.key !== 'string') continue;
if (!seen.has(e.key)) {
tabKeys[category].push(e.key);
seen.add(e.key);
}
if (typeof e.description === 'string') {
alertDescriptions[e.key] = e.description;
}
}
});
return { tabKeys, alertDescriptions };
}
function applyMergedRegistry(section, registry) {
const merged = mergeRegistryWithFallback(registry);
activeTabKeys = merged.tabKeys;
activeAlertDescriptions = merged.alertDescriptions;
window.Notifications.registry = registry;
Object.entries(activeTabKeys).forEach(([category, keys]) => {
const select = section.querySelector(`[data-notif-category="${category}"]`);
if (!select) return;
const existing = new Set(Array.from(select.options).map(o => o.value));
keys.forEach(key => {
if (!existing.has(key)) {
const option = document.createElement('option');
option.value = key;
option.textContent = toHumanLabel(key);
select.appendChild(option);
}
});
renderAlertDescription(category);
});
}
function initNotificationsEditor(config) {
const section = document.getElementById('s-notifications');
if (!section) return;
const hiddenField = section.querySelector('[data-key="NOTIFICATION_THRESHOLDS_JSON"]');
if (!hiddenField) return;
notificationThresholdsState = parseNotificationThresholdsConfig(config);
hiddenField.value = serializeNotificationThresholds(notificationThresholdsState);
section.querySelectorAll('.notif-tab-btn').forEach(btn => {
btn.addEventListener('click', () => setNotificationTab(btn.dataset.notifTab));
});
Object.entries(activeTabKeys).forEach(([category, keys]) => {
const select = section.querySelector(`[data-notif-category="${category}"]`);
const chipsWrap = section.querySelector(`[data-notif-chips="${category}"]`);
const input = section.querySelector(`[data-notif-input="${category}"]`);
const addBtn = section.querySelector(`[data-notif-add="${category}"]`);
const presetsWrap = section.querySelector(`[data-notif-presets="${category}"]`);
if (!select || !chipsWrap || !input || !addBtn || !presetsWrap) return;
keys.forEach(key => {
const option = document.createElement('option');
option.value = key;
option.textContent = toHumanLabel(key);
select.appendChild(option);
});
if (keys.length) select.value = keys[0];
select.addEventListener('change', () => {
renderThresholdChips(category);
renderAlertDescription(category);
renderPerAlertToggle(category);
});
addBtn.addEventListener('click', () => addThresholdFromInput(category));
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
addThresholdFromInput(category);
}
});
NOTIFICATION_PRESETS.forEach(preset => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = preset;
btn.addEventListener('click', () => addThresholdValue(category, preset));
presetsWrap.appendChild(btn);
});
renderThresholdChips(category);
renderAlertDescription(category);
});
setNotificationTab('surge');
wireEnableToggles(section);
// Background: pull canonical registry from the bot, merge with fallback,
// append any registry-only keys and refresh descriptions. Fallback stays
// in use if the endpoint 404s (settings-site deployed ahead of bot) or
// the fetch fails (network/proxy error).
fetchAlertRegistry().then(registry => {
if (!registry) return;
applyMergedRegistry(section, registry);
renderAllEnableUI(section);
}).catch(() => {});
// Phase 9: pull enable state and paint the toggles. Runs in parallel with
// the registry fetch above; renderAll is idempotent so ordering is OK.
fetchEnableState().then(state => {
if (!state) return;
enableState = state;
window.Notifications.state = state;
renderAllEnableUI(section);
}).catch(() => {});
}
function keysForCategory(category) {
return activeTabKeys[category] || [];
}
function renderMasterCheckboxes(section) {
section.querySelectorAll('[data-notif-master]').forEach(cb => {
cb.checked = enableState.master === true;
});
}
function renderCategoryCheckbox(section, category) {
const cb = section.querySelector(`[data-notif-category-toggle="${category}"]`);
if (!cb) return;
const keys = keysForCategory(category);
const allOn = keys.length > 0 && keys.every(k => enableState.perKey[k] === true);
cb.checked = enableState.master === true && allOn;
}
function renderPerAlertToggle(category) {
const section = document.getElementById('s-notifications');
if (!section) return;
const panel = section.querySelector(`[data-notif-panel="${category}"]`);
if (!panel) return;
const alertCb = panel.querySelector('[data-notif-alert]');
const label = panel.querySelector('[data-notif-alert-label]');
const alertKey = getSelectedAlertKey(category);
const on = alertKey ? enableState.perKey[alertKey] === true : false;
if (alertCb) alertCb.checked = on;
if (label) label.textContent = on ? 'Alert enabled' : 'Alert disabled';
// Disable the chip/input editor when master is off or this alert is off.
const editor = panel.querySelector('.notif-editor');
if (editor) {
const disabled = !enableState.master || !on;
editor.classList.toggle('notif-disabled', disabled);
}
}
function renderAllEnableUI(section) {
renderMasterCheckboxes(section);
Object.keys(activeTabKeys).forEach(cat => renderCategoryCheckbox(section, cat));
Object.keys(activeTabKeys).forEach(cat => renderPerAlertToggle(cat));
}
function wireEnableToggles(section) {
// Master — multiple DOM copies; they share state via enableState and
// re-render after each mutation, so clicking any one updates all.
section.querySelectorAll('[data-notif-master]').forEach(cb => {
cb.addEventListener('change', async () => {
const prev = enableState.master;
const next = cb.checked;
try {
const newState = await postToggle({ master: true, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
cb.checked = prev;
renderAllEnableUI(section);
Util.showToast('Failed to update master toggle.', 'error');
}
});
});
// Per-category "All in category" toggles.
section.querySelectorAll('[data-notif-category-toggle]').forEach(cb => {
const category = cb.dataset.notifCategoryToggle;
cb.addEventListener('change', async () => {
const next = cb.checked;
const prev = !next; // pre-toggle state, for revert on failure
try {
const newState = await postToggle({ category, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
cb.checked = prev;
renderAllEnableUI(section);
Util.showToast(`Failed to update ${category} category toggle.`, 'error');
}
});
});
// Per-alert toggles — one per category panel, scoped to the currently
// selected alertKey in that panel's dropdown.
section.querySelectorAll('.notif-panel').forEach(panel => {
const category = panel.dataset.notifPanel;
const alertCb = panel.querySelector('[data-notif-alert]');
if (!alertCb) return;
alertCb.addEventListener('change', async () => {
const alertKey = getSelectedAlertKey(category);
if (!alertKey) {
alertCb.checked = false;
return;
}
const prev = enableState.perKey[alertKey] === true;
const next = alertCb.checked;
try {
const newState = await postToggle({ key: alertKey, enabled: next });
enableState = newState;
window.Notifications.state = newState;
renderAllEnableUI(section);
} catch (e) {
alertCb.checked = prev;
renderAllEnableUI(section);
Util.showToast(`Failed to update ${alertKey} toggle.`, 'error');
}
});
});
}
function parseNotificationThresholdsConfig(config) {
const rawJson = config.NOTIFICATION_THRESHOLDS_JSON;
if (rawJson && String(rawJson).trim()) {
try {
const parsed = JSON.parse(rawJson);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch (_) {}
}
if (config.NOTIFICATION_THRESHOLDS && typeof config.NOTIFICATION_THRESHOLDS === 'object' && !Array.isArray(config.NOTIFICATION_THRESHOLDS)) {
return config.NOTIFICATION_THRESHOLDS;
}
return {};
}
function serializeNotificationThresholds(obj) {
const ordered = {};
Object.keys(obj).sort().forEach(key => {
const arr = Array.isArray(obj[key]) ? obj[key].map(v => String(v).trim()).filter(Boolean) : [];
ordered[key] = arr;
});
return JSON.stringify(ordered);
}
function setNotificationTab(category) {
document.querySelectorAll('#s-notifications .notif-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.notifTab === category);
});
document.querySelectorAll('#s-notifications .notif-panel').forEach(panel => {
panel.classList.toggle('hidden', panel.dataset.notifPanel !== category);
});
}
function addThresholdFromInput(category) {
const input = document.querySelector(`#s-notifications [data-notif-input="${category}"]`);
if (!input) return;
const value = input.value.trim().toLowerCase();
if (addThresholdValue(category, value)) input.value = '';
}
function addThresholdValue(category, rawValue) {
const value = String(rawValue || '').trim().toLowerCase();
if (!isValidThresholdValue(value)) {
Util.showToast('Invalid threshold format. Use 15m, 1h, 1d6h, or whole numbers.', 'error');
return false;
}
const alertKey = getSelectedAlertKey(category);
if (!alertKey) return false;
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
if (current.includes(value)) return false;
current.push(value);
notificationThresholdsState[alertKey] = current;
syncNotificationThresholdsField();
renderThresholdChips(category);
return true;
}
function removeThresholdValue(category, valueToRemove) {
const alertKey = getSelectedAlertKey(category);
if (!alertKey) return;
const current = Array.isArray(notificationThresholdsState[alertKey]) ? [...notificationThresholdsState[alertKey]] : [];
notificationThresholdsState[alertKey] = current.filter(v => String(v) !== String(valueToRemove));
syncNotificationThresholdsField();
renderThresholdChips(category);
}
function renderThresholdChips(category) {
const chipsWrap = document.querySelector(`#s-notifications [data-notif-chips="${category}"]`);
if (!chipsWrap) return;
const alertKey = getSelectedAlertKey(category);
const thresholds = alertKey && Array.isArray(notificationThresholdsState[alertKey])
? notificationThresholdsState[alertKey]
: [];
chipsWrap.replaceChildren();
thresholds.forEach(value => {
const chip = document.createElement('span');
chip.className = 'notif-chip';
chip.textContent = value;
const remove = document.createElement('button');
remove.type = 'button';
remove.title = `Remove ${value}`;
remove.textContent = '×';
remove.addEventListener('click', () => removeThresholdValue(category, value));
chip.appendChild(remove);
chipsWrap.appendChild(chip);
});
}
function renderAlertDescription(category) {
const descriptionEl = document.querySelector(`#s-notifications [data-notif-description="${category}"]`);
if (!descriptionEl) return;
const alertKey = getSelectedAlertKey(category);
descriptionEl.textContent = activeAlertDescriptions[alertKey] || 'No description available for this alert key yet.';
}
function syncNotificationThresholdsField() {
const hiddenField = document.querySelector('#s-notifications [data-key="NOTIFICATION_THRESHOLDS_JSON"]');
if (!hiddenField) return;
const serialized = serializeNotificationThresholds(notificationThresholdsState);
hiddenField.value = serialized;
Fields.markChanged('NOTIFICATION_THRESHOLDS_JSON', serialized);
hiddenField.classList.toggle('changed', Fields.isChanged('NOTIFICATION_THRESHOLDS_JSON'));
}
function getSelectedAlertKey(category) {
const select = document.querySelector(`#s-notifications [data-notif-category="${category}"]`);
return select ? select.value : '';
}
function isValidThresholdValue(value) {
if (!value) return false;
if (/^\d+$/.test(value)) return true;
return /^(\d+[mhd])+$/.test(value);
}
function toHumanLabel(key) {
return String(key)
.split('_')
.map(part => part.toUpperCase() === 'T2' || part.toUpperCase() === 'T3'
? part.toUpperCase()
: part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
window.Notifications = {
initNotificationsEditor,
isValidThresholdValue,
toHumanLabel,
fetchAlertRegistry,
fetchEnableState,
NOTIFICATION_PRESETS,
FALLBACK_TAB_KEYS,
FALLBACK_ALERT_DESCRIPTIONS,
registry: null,
state: enableState,
get tabKeys() { return activeTabKeys; },
get alertDescriptions() { return activeAlertDescriptions; }
};
})();

View File

@@ -10,7 +10,6 @@
'/behavior': 's-behavior',
'/threads': 's-threads',
'/pins': 's-pins',
'/notifications': 's-notifications',
'/logging': 's-logging',
'/automation': 's-automation',
'/appearance': 's-appearance',

View File

@@ -202,9 +202,6 @@ app.post('/api/config', apiLimiter, requireAuth, proxy('POST', '/config'));
app.get('/api/discord/guild', apiLimiter, requireAuth, proxy('GET', '/discord/guild'));
app.post('/api/restart', apiLimiter, requireAuth, proxy('POST', '/restart'));
app.get('/api/restart/status', apiLimiter, requireAuth, proxy('GET', '/restart/status'));
app.get('/api/notifications/alerts', apiLimiter, requireAuth, proxy('GET', '/notifications/alerts'));
app.get('/api/notifications/state', apiLimiter, requireAuth, proxy('GET', '/notifications/state'));
app.post('/api/notifications/toggle', apiLimiter, requireAuth, proxy('POST', '/notifications/toggle'));
app.get('/*splat', requireAuth, (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));

263
tests/configSchema.test.js Normal file
View File

@@ -0,0 +1,263 @@
import { describe, it, expect } from 'vitest';
import { ALLOWED_CONFIG_KEYS, getValidator } from '../services/configSchema.js';
describe('ALLOWED_CONFIG_KEYS', () => {
it('is a non-empty Set', () => {
expect(ALLOWED_CONFIG_KEYS).toBeInstanceOf(Set);
expect(ALLOWED_CONFIG_KEYS.size).toBeGreaterThan(0);
});
it('includes well-known runtime config keys', () => {
for (const k of [
'TICKET_CATEGORY_ID',
'AUTO_CLOSE_ENABLED',
'GMAIL_POLL_INTERVAL_SECONDS',
'EMBED_COLOR_OPEN',
'GAME_LIST'
]) {
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(true);
}
});
it('does not contain stale removed keys', () => {
for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) {
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false);
}
});
});
describe('getValidator: type inference', () => {
it('treats *_ENABLED as boolean', () => {
const v = getValidator('AUTO_CLOSE_ENABLED');
expect(v.type).toBe('boolean');
});
it('treats *_ID as discord_id', () => {
expect(getValidator('TICKET_CATEGORY_ID').type).toBe('discord_id');
});
it('overrides ROLE_ID_TO_PING (mid-key _ID) as discord_id', () => {
expect(getValidator('ROLE_ID_TO_PING').type).toBe('discord_id');
});
it('treats *_HOURS / *_MINUTES / *_SECONDS as integer', () => {
expect(getValidator('AUTO_CLOSE_AFTER_HOURS').type).toBe('integer');
expect(getValidator('RATE_LIMIT_WINDOW_MINUTES').type).toBe('integer');
expect(getValidator('GMAIL_POLL_INTERVAL_SECONDS').type).toBe('integer');
});
it('treats *_COLOR as hex_color', () => {
expect(getValidator('EMBED_COLOR_OPEN').type).toBe('hex_color');
});
it('treats LOGO_URL as url', () => {
expect(getValidator('LOGO_URL').type).toBe('url');
});
it('treats *_EMAIL as email', () => {
expect(getValidator('SUPPORT_EMAIL').type).toBe('email');
});
it('falls back to string for unknown shapes', () => {
expect(getValidator('TICKET_CATEGORY_NAME').type).toBe('string');
});
});
describe('boolean validator', () => {
const v = getValidator('AUTO_CLOSE_ENABLED');
it('accepts the literal true/false', () => {
expect(v.validate(true)).toEqual({ ok: true, coerced: true });
expect(v.validate(false)).toEqual({ ok: true, coerced: false });
});
it('accepts string "true"/"false"', () => {
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
});
it('rejects garbage', () => {
const res = v.validate('maybe');
expect(res.ok).toBe(false);
expect(res.error).toMatch(/true or false/);
});
});
describe('integer validator', () => {
const v = getValidator('AUTO_CLOSE_AFTER_HOURS');
it('coerces a numeric string to a number', () => {
expect(v.validate('72')).toEqual({ ok: true, coerced: 72 });
});
it('accepts zero', () => {
expect(v.validate('0')).toEqual({ ok: true, coerced: 0 });
});
it('rejects non-numeric strings', () => {
const res = v.validate('abc');
expect(res.ok).toBe(false);
expect(res.error).toMatch(/whole number/);
});
it('rejects floats', () => {
expect(v.validate('1.5').ok).toBe(false);
});
it('rejects negative integers', () => {
expect(v.validate('-5').ok).toBe(false);
});
it('treats empty input as ok with empty coerced value', () => {
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
expect(v.validate(null)).toEqual({ ok: true, coerced: '' });
expect(v.validate(undefined)).toEqual({ ok: true, coerced: '' });
});
});
describe('hex_color validator', () => {
const v = getValidator('EMBED_COLOR_OPEN');
it('accepts 0xRRGGBB form', () => {
expect(v.validate('0xFF00AA')).toEqual({ ok: true, coerced: '0xFF00AA' });
});
it('accepts #RRGGBB form and normalizes to 0xRRGGBB', () => {
expect(v.validate('#ff00aa')).toEqual({ ok: true, coerced: '0xFF00AA' });
});
it('accepts bare RRGGBB and normalizes', () => {
expect(v.validate('00ff00')).toEqual({ ok: true, coerced: '0x00FF00' });
});
it('rejects 3-digit shorthand', () => {
expect(v.validate('#abc').ok).toBe(false);
});
it('rejects garbage', () => {
expect(v.validate('purple').ok).toBe(false);
});
it('treats empty as ok', () => {
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
});
});
describe('url validator (LOGO_URL)', () => {
const v = getValidator('LOGO_URL');
it('accepts a full URL', () => {
expect(v.validate('https://example.com/logo.png')).toEqual({
ok: true,
coerced: 'https://example.com/logo.png'
});
});
it('rejects bare hostnames', () => {
expect(v.validate('example.com').ok).toBe(false);
});
it('treats empty as ok', () => {
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
});
});
describe('discord_id validator', () => {
const v = getValidator('TICKET_CATEGORY_ID');
it('accepts an 18-digit snowflake', () => {
expect(v.validate('123456789012345678')).toEqual({
ok: true,
coerced: '123456789012345678'
});
});
it('accepts a 20-digit snowflake', () => {
const id = '12345678901234567890';
expect(v.validate(id)).toEqual({ ok: true, coerced: id });
});
it('rejects too-short IDs', () => {
expect(v.validate('12345').ok).toBe(false);
});
it('rejects non-numeric strings', () => {
expect(v.validate('not-an-id').ok).toBe(false);
});
it('treats empty as ok', () => {
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
});
});
describe('discord_id_list validator', () => {
// ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it
// hits the string fallback. discord_id_list only fires for `*_IDS` keys, so
// exercise it with a hypothetical name.
const v = getValidator('STAFF_USER_IDS');
it('infers type discord_id_list for *_IDS keys', () => {
expect(v.type).toBe('discord_id_list');
});
it('accepts a single ID', () => {
expect(v.validate('123456789012345678'))
.toEqual({ ok: true, coerced: '123456789012345678' });
});
it('accepts a comma-separated list and trims spaces', () => {
expect(v.validate('123456789012345678, 987654321098765432'))
.toEqual({ ok: true, coerced: '123456789012345678,987654321098765432' });
});
it('rejects if any segment is not a snowflake', () => {
const res = v.validate('123456789012345678,nope');
expect(res.ok).toBe(false);
expect(res.error).toMatch(/not a Discord ID/);
});
it('treats empty as ok', () => {
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
});
});
describe('string validator (fallback)', () => {
const v = getValidator('TICKET_CATEGORY_NAME');
it('coerces "true"/"false" to booleans (legacy)', () => {
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
});
it('coerces numeric-looking strings to numbers (legacy)', () => {
expect(v.validate('42')).toEqual({ ok: true, coerced: 42 });
expect(v.validate('3.14')).toEqual({ ok: true, coerced: 3.14 });
});
it('passes plain strings through', () => {
expect(v.validate('Open Tickets')).toEqual({ ok: true, coerced: 'Open Tickets' });
});
it('passes empty string through unchanged', () => {
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
});
it('rejects null', () => {
expect(v.validate(null).ok).toBe(false);
});
});
describe('email validator', () => {
const v = getValidator('SUPPORT_EMAIL');
it('accepts valid email', () => {
expect(v.validate('support@example.com'))
.toEqual({ ok: true, coerced: 'support@example.com' });
});
it('rejects malformed strings', () => {
expect(v.validate('not-an-email').ok).toBe(false);
expect(v.validate('a@').ok).toBe(false);
expect(v.validate('@b').ok).toBe(false);
});
});

241
tests/utils.test.js Normal file
View File

@@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import {
stripEmailQuotes,
stripMobileFooter,
extractRawEmail,
escapeHtml,
sanitizeEmbedText,
truncateEmbedDescription,
replaceVariables,
getPriorityEmoji,
safeEqual,
isStaff
} from '../utils.js';
describe('stripEmailQuotes', () => {
it('strips "On X wrote:" reply quote', () => {
const input = 'My reply.\nOn Mon, May 5, 2025 at 1:00 PM Bob <bob@x.com> wrote:\n> previous message';
expect(stripEmailQuotes(input)).toBe('My reply.');
});
it('strips "From: …" reply header block', () => {
const input = 'New reply text.\nFrom: Bob <bob@x.com>\nSent: Monday\nSubject: Re: foo';
expect(stripEmailQuotes(input)).toBe('New reply text.');
});
it('strips "_____" signature underline', () => {
const input = 'My message.\n_____\nold thread content';
expect(stripEmailQuotes(input)).toBe('My message.');
});
it('returns empty string for empty input', () => {
expect(stripEmailQuotes('')).toBe('');
});
it('trims whitespace when no marker is found', () => {
expect(stripEmailQuotes(' hello ')).toBe('hello');
});
it('keeps body intact when "On" appears mid-text without "wrote:"', () => {
expect(stripEmailQuotes('I clicked On the button.')).toBe('I clicked On the button.');
});
it('normalizes CRLF before scanning', () => {
const input = 'New reply.\r\nOn Monday Bob <b@x.com> wrote:\r\n> quoted';
expect(stripEmailQuotes(input)).toBe('New reply.');
});
it('picks earliest cutoff when multiple markers match', () => {
// Earlier in the body: "On X wrote:". Later: "_____" underline.
// The earliest cutoff is the reply marker, not the underline.
const input = 'My new reply.\nOn Mon Bob wrote:\n> quoted text\n_____\nsignature';
expect(stripEmailQuotes(input)).toBe('My new reply.');
});
});
describe('stripMobileFooter', () => {
it('removes "Sent from my iPhone"', () => {
expect(stripMobileFooter('Hi\nSent from my iPhone').trim()).toBe('Hi');
});
it('removes "Sent from my Android"', () => {
expect(stripMobileFooter('Hi\nSent from my Android').trim()).toBe('Hi');
});
it('removes "Sent from my Galaxy"', () => {
expect(stripMobileFooter('Hi\nSent from my Galaxy').trim()).toBe('Hi');
});
it('removes "Get Outlook for iOS"', () => {
expect(stripMobileFooter('Hi\nGet Outlook for iOS').trim()).toBe('Hi');
});
it('returns input unchanged when no footer present', () => {
expect(stripMobileFooter('Just a normal message')).toBe('Just a normal message');
});
it('returns null/undefined unchanged', () => {
expect(stripMobileFooter(null)).toBe(null);
expect(stripMobileFooter(undefined)).toBe(undefined);
});
it('returns empty string unchanged', () => {
expect(stripMobileFooter('')).toBe('');
});
});
describe('extractRawEmail', () => {
it('extracts address from "Name <email>" form', () => {
expect(extractRawEmail('Bob <bob@example.com>')).toBe('bob@example.com');
});
it('returns trimmed input when angle brackets absent', () => {
expect(extractRawEmail(' bob@example.com ')).toBe('bob@example.com');
});
it('handles quoted name', () => {
expect(extractRawEmail('"Bob, the Developer" <bob@example.com>')).toBe('bob@example.com');
});
});
describe('escapeHtml', () => {
it('escapes <, >, &, ", \'', () => {
expect(escapeHtml('<script>alert("xss")</script>'))
.toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;');
expect(escapeHtml("a & b's <foo>")).toBe('a &amp; b&#39;s &lt;foo&gt;');
});
it('returns empty string for null/undefined', () => {
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
it('passes through plain text unchanged', () => {
expect(escapeHtml('plain text')).toBe('plain text');
});
});
describe('sanitizeEmbedText', () => {
it('replaces triple-backticks to prevent code-block escape', () => {
expect(sanitizeEmbedText('```injected```')).toBe("'''injected'''");
});
it('trims whitespace', () => {
expect(sanitizeEmbedText(' hello ')).toBe('hello');
});
it('returns empty string for null/undefined', () => {
expect(sanitizeEmbedText(null)).toBe('');
expect(sanitizeEmbedText(undefined)).toBe('');
});
});
describe('truncateEmbedDescription', () => {
it('returns short strings unchanged', () => {
expect(truncateEmbedDescription('hi')).toBe('hi');
});
it('truncates at default 4096 with ellipsis', () => {
const big = 'a'.repeat(5000);
const out = truncateEmbedDescription(big);
expect(out.length).toBe(4096);
expect(out.endsWith('...')).toBe(true);
});
it('respects custom max', () => {
expect(truncateEmbedDescription('abcdef', 5)).toBe('ab...');
});
it('returns empty string for null/undefined', () => {
expect(truncateEmbedDescription(null)).toBe('');
expect(truncateEmbedDescription(undefined)).toBe('');
});
});
describe('replaceVariables', () => {
it('substitutes ticket fields', () => {
const ctx = {
ticket: {
sender_name: 'Alice',
senderEmail: 'alice@x.com',
ticketNumber: 42,
subject: 'Help'
}
};
const out = replaceVariables('User {ticket.user} ({ticket.email}) #{ticket.number} - {ticket.subject}', ctx);
expect(out).toBe('User Alice (alice@x.com) #42 - Help');
});
it('falls back when fields missing', () => {
const out = replaceVariables('{ticket.user} {ticket.email} {ticket.subject}', { ticket: {} });
expect(out).toBe('Unknown No subject');
});
it('substitutes staff fields', () => {
const ctx = {
staff: { username: 'bob', displayName: 'Bob the Builder', mention: '<@123>' }
};
expect(replaceVariables('{staff.user} / {staff.name} / {staff.mention}', ctx))
.toBe('bob / Bob the Builder / <@123>');
});
it('returns empty string for empty template', () => {
expect(replaceVariables('')).toBe('');
expect(replaceVariables(null)).toBe('');
});
it('substitutes hours when provided', () => {
expect(replaceVariables('after {hours} hours', { hours: 24 })).toBe('after 24 hours');
});
it('substitutes {date} and {time} from current time', () => {
const out = replaceVariables('on {date}', {});
expect(out).toMatch(/^on \S+/);
expect(out).not.toContain('{date}');
});
});
describe('getPriorityEmoji', () => {
it('maps high/medium/low/normal to CONFIG values', () => {
expect(typeof getPriorityEmoji('high')).toBe('string');
expect(typeof getPriorityEmoji('low')).toBe('string');
expect(typeof getPriorityEmoji('medium')).toBe('string');
expect(typeof getPriorityEmoji('normal')).toBe('string');
});
it('falls back for unknown priority', () => {
expect(typeof getPriorityEmoji('weird')).toBe('string');
});
});
describe('safeEqual', () => {
it('returns true for matching strings', () => {
expect(safeEqual('hello', 'hello')).toBe(true);
});
it('returns false for mismatched strings', () => {
expect(safeEqual('hello', 'world')).toBe(false);
});
it('returns false for length mismatch (no throw)', () => {
expect(safeEqual('a', 'abc')).toBe(false);
});
it('returns false for null/undefined inputs', () => {
expect(safeEqual(null, 'abc')).toBe(false);
expect(safeEqual(undefined, undefined)).toBe(true);
expect(safeEqual('', '')).toBe(true);
});
});
describe('isStaff', () => {
it('returns false for null/undefined member', () => {
expect(isStaff(null)).toBe(false);
expect(isStaff(undefined)).toBe(false);
expect(isStaff({})).toBe(false);
});
it('returns false for member with no roles cache', () => {
expect(isStaff({ roles: null })).toBe(false);
});
});

140
utils.js
View File

@@ -112,22 +112,29 @@ function getCleanBody(payload) {
function stripEmailQuotes(text) {
let cleaned = text.replace(/\r\n/g, '\n');
// Pick the earliest match across all markers, not just the first marker that
// matches anywhere. The previous order-dependent loop could truncate at a
// late "_____" signature underline even when an earlier "On X wrote:" reply
// header was the real cutoff.
const markers = [
/\n_{5,}\s*$/m,
/\nOn .* wrote:/i,
/\nFrom:\s.*<.*@.*>/i,
/\nSent:\s.*$/i,
/\nTo:\s.*$/i,
/\nSubject:\s.*$/i,
/\nOn .* wrote:/i
/\n_{5,}\s*$/m
];
let earliest = -1;
for (const m of markers) {
const match = cleaned.match(m);
if (match) {
cleaned = cleaned.substring(0, match.index);
break;
if (match && (earliest === -1 || match.index < earliest)) {
earliest = match.index;
}
}
if (earliest !== -1) {
cleaned = cleaned.substring(0, earliest);
}
return cleaned.trim();
}
@@ -173,31 +180,6 @@ function extractRawEmail(headerValue) {
return match ? match[1].trim() : headerValue.trim();
}
// --- DATE ---
const getFormattedDate = () => {
const now = new Date();
const datePart = now
.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric'
})
.replace(/\//g, '-');
const timePart = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
});
const tzPart = new Intl.DateTimeFormat('en-US', {
timeZoneName: 'short'
})
.formatToParts(now)
.find(p => p.type === 'timeZoneName').value;
return `${datePart} ${timePart} ${tzPart}`;
};
// --- GAME DETECTION ---
// Map<lowercase-alias, { canonical, re }> built once at module load so detectGame
// doesn't allocate a fresh RegExp per game/alias per call.
@@ -233,16 +215,6 @@ function getPriorityEmoji(priority) {
}
}
function getPriorityColor(priority) {
switch (priority) {
case 'high': return 0xFF0000;
case 'low': return 0x00FF00;
case 'normal':
case 'medium':
default: return CONFIG.EMBED_COLOR_INFO;
}
}
// --- TEMPLATE VARIABLES ---
function replaceVariables(template, context = {}) {
@@ -292,13 +264,6 @@ function sanitizeEmbedText(str) {
// --- EMBED TRUNCATION ---
/** Truncate a string for use as an embed field value (max 1024). */
function truncateEmbedField(str, max = 1024) {
if (str == null) return '';
const s = String(str);
return s.length > max ? s.slice(0, max - 3) + '...' : s;
}
/** Truncate a string for use as an embed description (max 4096). */
function truncateEmbedDescription(str, max = 4096) {
if (str == null) return '';
@@ -306,99 +271,18 @@ function truncateEmbedDescription(str, max = 4096) {
return s.length > max ? s.slice(0, max - 3) + '...' : s;
}
/**
* Enforce the 6 000 char total embed limit across an array of EmbedBuilder
* instances. Mutates in place: trims the largest description first, then
* largest field values, until the total is under 6 000 chars.
* Returns the same array for chaining.
*/
function enforceEmbedLimit(embeds) {
const charCount = (e) => {
const d = e.data || {};
let total = 0;
if (d.title) total += d.title.length;
if (d.description) total += d.description.length;
if (d.footer?.text) total += d.footer.text.length;
if (d.author?.name) total += d.author.name.length;
if (d.fields) {
for (const f of d.fields) {
if (f.name) total += f.name.length;
if (f.value) total += f.value.length;
}
}
return total;
};
const LIMIT = 6000;
const totalChars = () => embeds.reduce((sum, e) => sum + charCount(e), 0);
// Trim largest descriptions first
while (totalChars() > LIMIT) {
let largestIdx = -1;
let largestLen = 0;
for (let i = 0; i < embeds.length; i++) {
const desc = embeds[i].data?.description;
if (desc && desc.length > largestLen) {
largestLen = desc.length;
largestIdx = i;
}
}
if (largestIdx === -1 || largestLen <= 4) break;
const excess = totalChars() - LIMIT;
const newLen = Math.max(1, largestLen - excess - 3);
embeds[largestIdx].setDescription(
embeds[largestIdx].data.description.slice(0, newLen) + '...'
);
if (totalChars() <= LIMIT) break;
// If still over, loop will pick next largest
}
// Trim largest field values
while (totalChars() > LIMIT) {
let targetEmbed = null;
let targetFieldIdx = -1;
let targetLen = 0;
for (const e of embeds) {
const fields = e.data?.fields || [];
for (let fi = 0; fi < fields.length; fi++) {
if (fields[fi].value && fields[fi].value.length > targetLen) {
targetLen = fields[fi].value.length;
targetEmbed = e;
targetFieldIdx = fi;
}
}
}
if (!targetEmbed || targetLen <= 4) break;
const excess = totalChars() - LIMIT;
const newLen = Math.max(1, targetLen - excess - 3);
targetEmbed.data.fields[targetFieldIdx].value =
targetEmbed.data.fields[targetFieldIdx].value.slice(0, newLen) + '...';
}
return embeds;
}
module.exports = {
sanitizeEmbedText,
truncateEmbedField,
truncateEmbedDescription,
enforceEmbedLimit,
BLOCK_TAG_REGEX,
escapeRegex,
escapeHtml,
safeEqual,
isStaff,
decodeHtmlEntities,
htmlToTextWithBlocks,
decodeGmailData,
getCleanBody,
stripEmailQuotes,
stripMobileFooter,
extractRawEmail,
getFormattedDate,
detectGame,
getPriorityEmoji,
getPriorityColor,
replaceVariables
};

View File

@@ -41,7 +41,10 @@ async function renameChannel(channelId, newName) {
if (res.status === 429) {
const retryAfterSec = (body && typeof body === 'object' && body.retry_after) || null;
const retryAfterMs = retryAfterSec != null ? Math.ceil(Number(retryAfterSec) * 1000) : null;
logWarn('renamer', `429 rename channel=${channelId} retry_after=${retryAfterSec}`).catch(() => {});
// Local log only; the channelQueue fallback path handles recovery
// transparently via discord.js's built-in 429 retry. Posting these to
// the debug channel was non-actionable noise.
console.warn(`[renamer] 429 rename channel=${channelId} retry_after=${retryAfterSec}`);
// Respect retry_after up to 2000ms; otherwise fail over immediately.
if (retryAfterMs != null && retryAfterMs > 0 && retryAfterMs <= 2000) {

10
vitest.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.js'],
globals: false,
testTimeout: 10000
}
});