43 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
5de05a0d01 strip: remove unused CloseRequest mongoose model
Close-button confirmation uses the in-memory pendingCloses Map
(handlers/pendingCloses.js), not this persisted schema. Zero readers.
2026-04-21 16:57:09 +00:00
34dc55c20b strip: remove /backup /export /search /stats /fix-stale-tickets + analytics module
- delete handlers/analytics.js
- remove trackInteraction calls; replace trackError with logError().catch(() => {})
- remove 5 slash commands from register.js
- remove BACKUP_EXPORT_CHANNEL_ID from config + schema + .env.example
2026-04-21 16:44:01 +00:00
fa7d4af132 strip: delete stale docs/ and broccolini_bot_context.md
Both were saturated with references to removed features.
Regenerate fresh post-MVP.
2026-04-21 16:32:05 +00:00
ca737039f8 strip: remove data-forensics scripts from parent IB-Discord-Bot project
Kept: backup-env.js, test-mongodb.js (wired to npm run test-mongodb).
2026-04-21 16:19:39 +00:00
bf901039bc mvp & email signature 2026-04-21 16:15:18 +00:00
071fae2ea3 strip: drop gitlab CI, stray git/ dir, FEATURES.md; untrack CLAUDE.md; ignore *.bak* 2026-04-21 16:04:54 +00:00
3300a7fc19 untrack CLAUDE.md (local-only) 2026-04-21 16:03:00 +00:00
1a46fb696a cleanup: remove strip backup files 2026-04-21 15:57:51 +00:00
636348d824 strip: remove pattern/surge/chat alert monitoring + unused commands
- delete services/{patternChecker,patternStore,surgeChecker,chatAlertChecker,staffNotifications,staffChannel,notificationRegistry,notificationEnabled,staffPresence}.js
- remove /notification, /staffnotification, /tag, /priority
- /escalate: drop action param, always unclaim
- purge PATTERN_*, SURGE_*, CHAT_ALERT_*, STAFF_* env vars from config + .env.example
- drop StaffNotification model
- ~2500 LOC removed
- settings-site /internal/notifications/* endpoints gone (UI will 404 until trimmed)
2026-04-21 15:57:18 +00:00
105 changed files with 4833 additions and 18540 deletions

View File

@@ -1,51 +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 *)"
]
}
}

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,9 +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
BACKUP_EXPORT_CHANNEL_ID= # Channel where /backup and /export post .txt files; optional
ACCOUNT_INFO_CHANNEL_ID= # Channel for account info lookups; optional
DISCORD_CHANNEL_ID= # General Discord channel (if used)
# --- Discord: Ticket copy & buttons ---
# ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME
@@ -54,16 +47,10 @@ 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)
# --- bOSScord (support cockpit) ---
# Set BOSSCORD_API_KEY to enable /api (ticket list, thread, send message). Use same key in bOSScord app login.
# BOSSCORD_API_KEY= # e.g. from: openssl rand -hex 32
# BOSSCORD_CORS_ORIGIN=* # Optional; default * (set to bOSScord origin in production)
# --- Database ---
MONGODB_URI= # MongoDB connection string (e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db)
# MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently)
@@ -83,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
@@ -92,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
@@ -112,16 +97,10 @@ PRIORITY_MEDIUM_EMOJI=🟡
PRIORITY_LOW_EMOJI=🟢
# --- Claiming ---
CLAIM_TIMEOUT_ENABLED=false
CLAIM_TIMEOUT_HOURS=48
AUTO_UNCLAIM_ENABLED=false
AUTO_UNCLAIM_AFTER_HOURS=24
ALLOW_CLAIM_OVERWRITE=false
STAFF_EMOJIS=224692549225283584:🍅 # userId:emoji pairs, comma-separated
CLAIMER_EMOJI_FALLBACK=🎫 # fallback if claimer has no entry in STAFF_EMOJIS
ADMIN_ID= # Discord user ID of the bot admin (for /staffnotification)
STAFF_NOTIFICATION_CATEGORY_ID= # Category for staff notification channels (created by /notification add)
UNCLAIMED_REMINDER_THRESHOLDS=1,2,4 # Comma-separated hour thresholds for unclaimed ticket alerts
ADMIN_ID= # Discord user ID of the bot admin (for Gmail OAuth failure DMs)
FORCE_CLOSE_TIMER_SECONDS=60 # Seconds to wait before force-closing a ticket (default 60)
GMAIL_POLL_INTERVAL_SECONDS=30 # Gmail poll interval in seconds (default 30)
GMAIL_LOG_CHANNEL_ID= # Channel for Gmail poll activity logs
@@ -130,46 +109,6 @@ RENAME_LOG_CHANNEL_ID= # Channel for channel rename queue log
SECURITY_LOG_CHANNEL_ID= # Channel for security/audit logs
SYSTEM_LOG_CHANNEL_ID= # Channel for bot lifecycle logs (startup, shutdown, DB events)
# --- Pattern detection ---
USER_PATTERNS_CHANNEL_ID= # Channel for repeat-user pattern alerts
GAME_PATTERNS_CHANNEL_ID= # Channel for game-specific pattern alerts
TAG_PATTERNS_CHANNEL_ID= # Channel for ticket tag pattern alerts
ESCALATION_PATTERNS_CHANNEL_ID= # Channel for escalation pattern alerts
STAFF_PATTERNS_CHANNEL_ID= # Channel for staff workload pattern alerts
COMBINED_PATTERNS_CHANNEL_ID= # Channel for combined/cross-cutting pattern alerts
PATTERN_USER_TICKET_THRESHOLD=3 # Tickets per user before alerting
PATTERN_GAME_TICKET_THRESHOLD=10 # Tickets per game before alerting
PATTERN_STAFF_STALE_PING_THRESHOLD=5 # Stale pings before alerting
PATTERN_ESCALATION_THRESHOLD=3 # Escalations before alerting
PATTERN_RAPID_CLOSE_SECONDS=120 # Seconds; closes faster than this are flagged
PATTERN_UNCLAIMED_HOURS=4 # Hours unclaimed before flagging
PATTERN_CHECK_INTERVAL_MINUTES=30 # Minutes between pattern check runs
# --- Surge & chat alerts ---
ALL_STAFF_CHANNEL_ID= # Channel for staff surge alerts
ALL_STAFF_CHAT_ALERT_CHANNEL_ID= # Channel for chat monitoring alerts
SURGE_ROLE_ID= # Role to ping on surge alerts
SURGE_TICKET_COUNT=10 # Ticket count to trigger surge
SURGE_TICKET_WINDOW_MINUTES=30 # Window for ticket surge
SURGE_GAME_TICKET_COUNT=5 # Per-game ticket count for surge
SURGE_GAME_TICKET_WINDOW_MINUTES=30 # Window for game surge
SURGE_STALE_COUNT=8 # Stale tickets to trigger alert
SURGE_STALE_HOURS=2 # Hours before ticket is stale
SURGE_NEEDS_RESPONSE_COUNT=5 # Tickets awaiting response to trigger alert
SURGE_NEEDS_RESPONSE_HOURS=1 # Hours awaiting response
SURGE_UNCLAIMED_COUNT=5 # Unclaimed tickets for surge alert
SURGE_UNCLAIMED_MINUTES=30 # Minutes unclaimed before counting
SURGE_TIER3_UNCLAIMED_MINUTES=15 # Minutes before tier 3 unclaimed alert
SURGE_COOLDOWN_MINUTES=60 # Cooldown between surge alerts
CHAT_ALERT_CHANNEL_IDS= # Comma-separated channel IDs to monitor
CHAT_ALERT_MESSAGE_COUNT=5 # Unresponded messages to trigger alert
CHAT_ALERT_HOURS_WITHOUT_RESPONSE=2 # Hours without staff response to alert
CHAT_ALERT_COOLDOWN_MINUTES=60 # Cooldown between chat alerts
STAFF_IDS= # Comma-separated Discord user IDs of all staff members
SURGE_NO_STAFF_COOLDOWN_MINUTES=30 # Cooldown between zero-staff alerts
SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD=3 # Min open tickets before alerting
STAFF_DND_COUNTS_AS_AVAILABLE=false # Whether DND status counts as available
# --- Staff threads ---
STAFF_THREAD_ENABLED=false # Create a private staff thread on each ticket channel
STAFF_THREAD_NAME=Staff Discussion # Name of the private thread
@@ -190,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

View File

@@ -1,128 +0,0 @@
# =============================================================================
# Broccolini Bot Test environment template (no secrets)
# Copy to .env.test and fill with TEST-only values. Run with ENV_FILE=.env.test
# so changes are tried here first, then migrated to .env after confirmation.
# See docs/setup/ENV_AND_SECURITY.md. Never commit .env or .env.test.
# =============================================================================
# --- Discord: Core (use a test guild / bot if possible) ---
DISCORD_TOKEN= # Bot token (test bot)
DISCORD_APPLICATION_ID= # Application (client) ID
DISCORD_GUILD_ID= # Test server ID
# --- Discord: Channel & category IDs (test server) ---
# Ticket creation: set one or both; /panel and /email-routing choose behavior
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels (test)
TICKET_CATEGORY_ID= # Category for email-originated ticket channels (test)
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.)
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; optional for minimal test)
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
EMAIL_ESCALATED_CATEGORY_ID= # Fallback escalation category (email); legacy alias: ESCALATED_CATEGORY_ID
DISCORD_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category/channel (Discord)
DISCORD_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category/channel (Discord)
EMAIL_ESCALATED2_CHANNEL_ID= # Tier 2 escalation category ID (email); env name *_CHANNEL_* is legacy
EMAIL_ESCALATED3_CHANNEL_ID= # Tier 3 escalation category ID (email)
# --- Logging, transcripts, and utility ---
ROLE_ID_TO_PING=
TRANSCRIPT_CHANNEL_ID=
LOGGING_CHANNEL_ID=
DEBUGGING_CHANNEL_ID=
BACKUP_EXPORT_CHANNEL_ID=
ACCOUNT_INFO_CHANNEL_ID=
DISCORD_CHANNEL_ID=
# --- Discord: Ticket copy & buttons ---
ESCALATION_MESSAGE=
BUTTON_LABEL_CLOSE=Close Ticket
BUTTON_LABEL_CLAIM=Claim
BUTTON_LABEL_UNCLAIM=Unclaim
BUTTON_EMOJI_CLOSE=🔒
BUTTON_EMOJI_CLAIM=📌
BUTTON_EMOJI_UNCLAIM=🔓
# --- Google / Gmail (test inbox / separate OAuth client optional) ---
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
REFRESH_TOKEN=
MY_EMAIL=
# --- Server & URLs ---
# NGROK_URL= # Optional; public URL if you use ngrok for webhooks
DISCORD_ONLY_PORT=5000
# HEALTHCHECK_HOST=
# --- bOSScord (support cockpit) ---
# BOSSCORD_API_KEY=
# BOSSCORD_CORS_ORIGIN=*
# --- Database (test cluster or local) ---
MONGODB_URI= # e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db_test?authSource=broccoli_db_test
# MONGODB_DATABASE=
# --- Branding & copy ---
SUPPORT_NAME=Support
LOGO_URL=
EMAIL_SIGNATURE=
TICKET_CLOSE_SUBJECT_PREFIX=[Resolved]
TICKET_CLOSE_MESSAGE=
TICKET_CLOSE_SIGNATURE=
DISCORD_CLOSE_MESSAGE=
DISCORD_TRANSCRIPT_MESSAGE=
DISCORD_AUTO_CLOSE_MESSAGE=
# --- Ticket limits & permissions ---
GLOBAL_TICKET_LIMIT=5
TICKET_LIMIT_PER_CATEGORY=3
RATE_LIMIT_TICKETS_PER_USER=0
RATE_LIMIT_WINDOW_MINUTES=60
BLACKLISTED_ROLES=
ADDITIONAL_STAFF_ROLES=
# --- Auto-close ---
AUTO_CLOSE_ENABLED=false
AUTO_CLOSE_AFTER_HOURS=72
AUTO_CLOSE_MESSAGE=
# --- Reminders ---
REMINDER_ENABLED=false
REMINDER_AFTER_HOURS=24
REMINDER_MESSAGE=
TICKET_WELCOME_MESSAGE=
TICKET_CLAIMED_MESSAGE=
TICKET_UNCLAIMED_MESSAGE=
# --- Priority ---
PRIORITY_ENABLED=false
DEFAULT_PRIORITY=normal
PRIORITY_HIGH_EMOJI=🔴
PRIORITY_MEDIUM_EMOJI=🟡
PRIORITY_LOW_EMOJI=🟢
# --- Claiming ---
CLAIM_TIMEOUT_ENABLED=false
CLAIM_TIMEOUT_HOURS=48
AUTO_UNCLAIM_ENABLED=false
AUTO_UNCLAIM_AFTER_HOURS=24
ALLOW_CLAIM_OVERWRITE=false
# --- Thread-style tickets (legacy) ---
USE_THREADS=false
THREAD_PARENT_CHANNEL=
# --- 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
# --- Game list (comma-separated; used for detection and tags) ---
GAME_LIST=Project Zomboid, Minecraft

4
.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,4 +47,6 @@ cursor.yml
*.local.yml
.claude/
CLAUDE.md
*.bak*

View File

@@ -1,19 +0,0 @@
# You can override the included template(s) by including variable overrides
# SAST customization: https://docs.gitlab.com/user/application_security/sast/#available-cicd-variables
# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure/
# Dependency Scanning customization: https://docs.gitlab.com/user/application_security/dependency_scanning/#customizing-analyzer-behavior
# Container Scanning customization: https://docs.gitlab.com/user/application_security/container_scanning/#customizing-analyzer-behavior
# Note that environment variables can be set in several places
# See https://docs.gitlab.com/ci/variables/#cicd-variable-precedence
stages:
- test
- secret-detection
sast:
stage: test
include:
- template: Security/SAST.gitlab-ci.yml
- template: Security/Secret-Detection.gitlab-ci.yml
variables:
SECRET_DETECTION_ENABLED: 'true'
secret_detection:
stage: secret-detection

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`.

View File

@@ -1,87 +0,0 @@
## Broccolini Bot Feature Overview
Broccolini Bot is a Discord support bot that turns Gmail emails and Discord messages into trackable support tickets stored in MongoDB.
---
## Email & Discord Ticketing
**Summary:** Connects Gmail and Discord so each support conversation becomes a ticket channel or thread.
- Email → Discord ticket channels or threads (with overflow categories)
- Discord-only tickets created from panels or context menus
- Full Gmail reply threading for email-sourced tickets
- Ticket transcripts saved to a Discord channel and optionally emailed on close
---
## Ticket Workflow & Management
**Summary:** Provides a structured workflow for creating, handling, and closing tickets.
- Claim / unclaim with claimer emojis in channel names
- Priority levels (low / normal / medium / high) with emojis
- Escalation and de-escalation between tiered support categories
- Close confirmation, force-close, and automatic transcript generation
- Auto-close, inactivity reminders, and auto-unclaim (configurable)
- Per-ticket limits and global ticket limits to prevent abuse
---
## Discord UI: Panels, Buttons & Modals
**Summary:** Uses rich Discord components so users and staff interact with tickets through buttons and forms.
- `/panel` command to post “Open ticket” panels
- Ticket creation via modal (email, game, description fields)
- Ticket action row with Close, Claim, Escalate, and De-escalate buttons
- Thread-style or category-channel tickets, or panels that offer both
- `/setup` wizard to guide initial panel and category configuration
---
## Staff Tools & Notifications
**Summary:** Gives staff better visibility and control over tickets and workloads.
- `/add` and `/remove` to manage who can see a ticket
- `/transfer`, `/move`, `/topic`, `/stats`, `/search`, `/backup`, `/export`
- `/accountinfo` for account lookups by email or Discord user
- Per-staff notification channels with reply alerts and unclaimed digests
- Optional DM reply alerts via `/notifydm`
- Optional private staff-only threads attached to ticket channels
---
## Tags, Saved Responses & Variables
**Summary:** Speeds up replies and keeps tickets categorized.
- `/tag` command with predefined ticket tags and emojis
- `/response` commands to create, edit, send, delete, and list saved replies
- Template variables (ticket, staff, server, date/time, hours, etc.) in responses
- Tag usage and response usage tracked in MongoDB
---
## Automation, Patterns & Surge Detection
**Summary:** Monitors ticket volume and chat activity to warn staff about problems early.
- Background jobs for auto-close, reminders, and auto-unclaim
- Pattern detection for repeat users, games, tags, escalations, and stale tickets
- Surge detection for high ticket volume, backlogs, and no-staff situations
- Chat alerts for busy channels or messages without staff replies
---
## Settings UI, Logging, API & Configuration
**Summary:** Provides a web UI plus environment-based configuration and optional integrations.
- Optional Broccolini settings web UI (`settings-site/`) to edit Discord channels, categories, Gmail credentials, ticket behavior, surge alerts, pattern thresholds, appearance, and advanced options without touching `.env`
- All behavior still backed by `.env` and `config.js` (messages, colors, timeouts, limits)
- Dedicated Discord channels for transcripts, logs, security, automation, and Gmail polling
- Optional HTTP API under `/api` with token-based auth
- Healthcheck endpoint (`GET /`) for Docker and load balancers

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,24 +11,15 @@ const { mongoose } = require('./db-connection');
// Handlers
const { handleButton, handleTicketModal } = require('./handlers/buttons');
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
const { handleSendAccountInfoToChannel, BUTTON_PREFIX } = require('./handlers/accountinfo');
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 { notifyAllStaffUnclaimed } = require('./services/staffNotifications');
const { registerCommands } = require('./commands/register');
const bosscordRoutes = require('./routes/bosscord');
const { setBot } = require('./api/bosscordClient');
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.
@@ -96,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 {
@@ -115,36 +106,14 @@ async function runHandler(name, interaction, fn) {
}
client.on('interactionCreate', async interaction => {
if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) {
const handled = await runHandler('handleSendAccountInfoToChannel', interaction, () => handleSendAccountInfoToChannel(interaction));
if (handled) return;
}
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');
@@ -167,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;
@@ -183,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));
}
@@ -202,18 +166,17 @@ client.on('interactionCreate', async interaction => {
});
client.on('messageCreate', async msg => {
// Track staff last-seen for zero-staff detection fallback
if (!msg.author.bot && CONFIG.STAFF_IDS.includes(msg.author.id)) {
const { updateStaffLastSeen } = require('./services/patternStore');
updateStaffLastSeen(msg.author.id);
}
// Chat channel monitoring
const { handleChatMessage } = require('./services/chatAlertChecker');
await handleChatMessage(msg, client).catch(() => {});
// Existing ticket reply handler
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.');
@@ -221,14 +184,6 @@ client.once('ready', async () => {
}
await connectMongoDB(process.env.MONGODB_URI);
setDebugClient(client);
setBot(client);
if (process.env.BOSSCORD_API_KEY) {
app.use('/api', bosscordRoutes);
app.use('/api', (err, req, res, next) => {
console.error('bOSScord API error:', err && err.stack ? err.stack : err);
res.status(500).json({ error: 'Internal server error' });
});
}
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
@@ -262,61 +217,22 @@ client.once('ready', async () => {
console.log('✓ Auto-close enabled: checking every hour');
}
trackInterval(setInterval(() => notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e)), 30 * 60 * 1000));
notifyAllStaffUnclaimed(client).catch(e => console.error('notifyAllStaffUnclaimed:', e));
console.log('✓ Staff unclaimed reminders: checking every 30 minutes');
if (CONFIG.AUTO_UNCLAIM_ENABLED) {
trackInterval(setInterval(() => checkAutoUnclaim(client), 60 * 60 * 1000));
checkAutoUnclaim(client);
console.log('✓ Auto-unclaim enabled: checking every hour');
}
const { runPatternChecks } = require('./services/patternChecker');
const { scheduleResets } = require('./services/patternStore');
scheduleResets();
trackInterval(setInterval(() => runPatternChecks(client).catch(e => console.error('runPatternChecks:', e)), CONFIG.PATTERN_CHECK_INTERVAL_MINUTES * 60 * 1000));
console.log(`✓ Pattern checks: every ${CONFIG.PATTERN_CHECK_INTERVAL_MINUTES} minutes`);
const { runSurgeChecks } = require('./services/surgeChecker');
trackInterval(setInterval(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 5 * 60 * 1000));
setTimeout(() => runSurgeChecks(client).catch(e => console.error('runSurgeChecks:', e)), 30000);
console.log('✓ Surge checks: every 5 minutes');
const { initChatMonitoring, runChatAlertChecks } = require('./services/chatAlertChecker');
initChatMonitoring(client);
trackInterval(setInterval(() => runChatAlertChecks(client).catch(e => console.error('runChatAlertChecks:', e)), 5 * 60 * 1000));
console.log('✓ Chat alert monitoring: every 5 minutes');
reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e));
trackInterval(setInterval(() => reconcileDeletedTicketChannels(client).catch(e => console.error('reconcileDeletedTicketChannels:', e)), 60 * 60 * 1000));
resumePendingDeletes(client).catch(e => console.error('resumePendingDeletes:', e));
console.log('✓ Reconcile deleted ticket channels: every 1 hour');
// Start in-memory Map sweeps (per-module) — keeps long-running processes bounded.
require('./services/patternStore').startSweeps(trackInterval);
require('./services/staffNotifications').startSweeps(trackInterval);
require('./services/tickets').startTicketsSweeps(trackInterval);
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
if (!CONFIG.STAFF_IDS.length) {
console.warn('[surgeChecker] STAFF_IDS is not set — zero-staff detection disabled.');
}
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: 'Claim timeout', value: CONFIG.CLAIM_TIMEOUT_ENABLED ? `enabled (${CONFIG.CLAIM_TIMEOUT_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);
@@ -324,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.' });
@@ -339,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
@@ -360,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 (_) {}
}
@@ -389,13 +300,5 @@ module.exports = {
client,
setGmailPollInterval,
clearGmailPollInterval,
trackTimeout,
sendGmailReply,
sendTicketClosedEmail,
getNextTicketNumber,
getCleanBody,
detectGame,
stripEmailQuotes,
stripMobileFooter,
htmlToTextWithBlocks
trackTimeout
};

View File

@@ -1,512 +0,0 @@
# broccolini_bot_context.md
Single-source structural map of `/opt/broccolini-bot`. Generated for review use; not authoritative over code — re-read files before acting on anything here.
## Overview
Node.js (CommonJS) Discord ticketing bot for Indifferent Broccoli. Single process hosts:
- A discord.js v14 client (ticket lifecycle, slash/button/modal handlers, context menus)
- A Gmail bridge (~30s polling → Discord channels; staff replies → Gmail)
- A Mongoose/self-hosted MongoDB layer (`broccoli_db`) for tickets + settings
- Two Express servers: healthcheck + bOSScord API (`PORT`, default 5000 → host 8892), and an internal settings API (`INTERNAL_API_PORT`)
- Background jobs: auto-close, unclaimed reminders, auto-unclaim, pattern detection, surge detection, chat monitoring, orphan reconciliation
Container: `docker compose up --build -d`. Port 5000 inside → 8892 outside. No test runner, linter, or build step.
**CLAUDE.md Hard Rule #3 clarification:** the repo's `services/channelQueue.js` only exposes `enqueueRename` / `enqueueMove`. There is no `enqueueSend`. In practice the rule applies to **renames and category moves**, not to `channel.send`. Direct `channel.send` is the norm throughout `handlers/` and is not treated as a violation in this document.
## File tree (one-line purposes)
### Root
- `broccolini-discord.js` — entry point; wires client, events, background jobs, two HTTP servers
- `config.js` — env → `CONFIG` object (119 vars, lines 111276); game list, tags, staff emoji map
- `db-connection.js` — Mongo connect + require `models.js`; retry helper, shutdown hook
- `models.js`**all 13 Mongoose schemas in one file**
- `gmail-poll.js` — Gmail inbox poll → new ticket creation / follow-up routing
- `get-refresh-token.js` — one-shot OAuth helper (redirect `http://localhost:3000/oauth2callback`)
- `utils.js` — email/game helpers, response template variables
- `package.json` / `Dockerfile` / `docker-compose.yml` — deploy
- `.env.example` / `.env.test.example` — env reference
### `handlers/`
- `buttons.js` — button + modal interactions: claim/unclaim, close confirm, escalate T2/T3, de-escalate, priority, tag, ticket-creation modal
- `commands.js` — slash command router: `/escalate`, `/deescalate`, `/add`, `/remove`, `/transfer`, `/move`, `/claim`, `/unclaim`, `/close`, `/priority`, `/tags`, `/email-routing`, `/setup`, `/help`, `/stats`, `/history`, `/search`, `/notification`, `/staffthread`, `/pinmessages`, `/panel`, `/backup`, `/export`, `/accountinfo`, `/gmailpoll`
- `messages.js``messageCreate`: staff reply → Gmail relay; notify claimer on customer reply; DM alert toggle
- `setup.js` — multi-step `/setup` wizard (modals + select menus)
- `accountinfo.js``/accountinfo` lookup + "send to channel" context menu
- `analytics.js` — in-memory counters: interactions, errors, uptime
- `pendingCloses.js` — shared `Map<channelId, timeout>` for force-close timer
### `services/`
- `channelQueue.js``enqueueRename` (p-queue-style, serialized per channel, respects Discord's 2-rename/10-min cap) and `enqueueMove` (direct `setParent`)
- `tickets.js` — counters, naming, rate limits, auto-close, auto-unclaim, `reconcileDeletedTicketChannels`
- `gmail.js``getGmailClient`, `sendGmailReply`, `sendTicketClosedEmail`, `sendTicketNotificationEmail`
- `debugLog.js` — fire-and-forget logging to dedicated Discord channels (`logError`, `logWarn`, `logTicketEvent`, `logGmail`, `logAutomation`, `logSecurity`, `logIntegrity`, `logSystem`)
- `staffNotifications.js``notifyStaffOfReply` (per-ticket cooldown), `notifyAllStaffUnclaimed` (30-min digest)
- `staffSettings.js``StaffSettings.notifyDm` get/set
- `staffSignature.js` — per-staff valediction/display name/tagline blocks
- `staffPresence.js` — presence + message-activity tracking for "no staff available" surge alerts
- `staffThread.js` — optional per-ticket private staff thread + auto-add members of `STAFF_THREAD_ROLE_ID`
- `staffChannel.js`**deprecated.** Legacy per-staffer mirror channels. `STAFF_CATEGORIES` is empty in current `config.js`; `createStaffChannel` is not called from the claim flow.
- `pinMessage.js` — pin helper with optional system-message suppression
- `patternStore.js` — in-memory counter store with scheduled daily/weekly/monthly resets, escalating-cooldown helper
- `patternChecker.js` — periodic pattern detection (user/game/tag/escalation/staff)
- `surgeChecker.js` — volume, game, stale, needs-response, unclaimed, T3-unclaimed, no-staff surge alerts
- `chatAlertChecker.js` — monitor configured chat channels for unresponded messages
- `configPersistence.js` — save/load runtime config to Mongo
- `guildSettings.js` — per-guild `emailRouting` (`thread` | `category`)
### `commands/`
- `register.js` — slash + context menu registration via discord.js REST v10
### `routes/`
- `bosscord.js``/api/tickets*` for bOSScord (Bearer `BOSSCORD_API_KEY`, CORS, DB-ready gate)
- `internalApi.js``/internal/*` for the settings site (`X-Internal-Secret`)
### `api/`
- `bosscordClient.js` — singleton holder for the Discord client (set at startup, read by routes)
### `settings-site/`
- Separate Express app. `server.js` talks to the bot's internal API over `INTERNAL_API_PORT` using `INTERNAL_API_SECRET`. Password-protected dashboard (`SETTINGS_ADMIN_PASSWORD`).
### `scripts/`
- `test-mongodb.js` — connectivity smoke test (`npm run test-mongodb`)
### `docs/`
- `README.md`, `CRITICAL_FILES_AND_HOW_IT_WORKS.md`, `setup/*`, `features/*`, `api/*`, `architecture/*`
## Discord event handler map
| Event | Wired in | Dispatch |
|-------|----------|----------|
| `ready` | `broccolini-discord.js` (single-fire) | DB connect → `registerCommands()` → mount bOSScord API → start 8 background intervals → start internal API server |
| `interactionCreate` | `broccolini-discord.js` | Routes by type: `isButton` / `isModalSubmit``handlers/buttons.js` and `handlers/setup.js`; `isChatInputCommand``handlers/commands.js`; `isContextMenuCommand``handlers/accountinfo.js`; `isAutocomplete` → tags/responses |
| `messageCreate` | `broccolini-discord.js` | `staffPresence.updateStaffLastSeen``chatAlertChecker.handleChatMessage``handlers/messages.handleDiscordReply` |
| `unhandledRejection` | `broccolini-discord.js` | `logError('unhandledRejection', …).catch(() => {})` |
| `SIGTERM`/`SIGINT` | `broccolini-discord.js` | `handleShutdown()` — log + exit |
### Background intervals (all started in `ready`)
| Job | Interval | Source | Config gate |
|-----|----------|--------|-------------|
| Gmail poll | `GMAIL_POLL_INTERVAL_MS` (~30s) | `gmail-poll.js:poll` | always on |
| Auto-close | 60 min | `services/tickets.checkAutoClose` | `AUTO_CLOSE_ENABLED` |
| Unclaimed digest | 30 min | `services/staffNotifications.notifyAllStaffUnclaimed` | `UNCLAIMED_REMINDER_THRESHOLDS` |
| Auto-unclaim | 60 min | `services/tickets.checkAutoUnclaim` | `AUTO_UNCLAIM_*` |
| Pattern checks | `PATTERN_CHECK_INTERVAL_MINUTES` | `services/patternChecker.runPatternChecks` | pattern channel envs |
| Surge checks | 5 min (+30s initial delay) | `services/surgeChecker.runSurgeChecks` | `ALL_STAFF_CHANNEL_ID` |
| Chat monitoring | 5 min | `services/chatAlertChecker.runChatAlertChecks` | `CHAT_ALERT_CHANNEL_IDS` |
| Orphan reconciliation | 60 min | `services/tickets.reconcileDeletedTicketChannels` | always on |
### Button / modal custom IDs
`open_ticket`, `open_ticket_thread`, `open_ticket_channel`, `email_routing_thread`, `email_routing_category`, `claim_ticket`, `close_ticket`, `confirm_close`, `cancel_close`, `escalate_ticket`, `escalate_to_tier2`, `escalate_to_tier3`, `deescalate_ticket`, `priority_*`, `open_panel`, `ticket_modal`, `ticket_modal_thread`, `ticket_modal_channel`, `setup_*` (wizard), `send_account_info_*`.
## Ticket lifecycle
Two sources, one `Ticket` document:
- **Email-sourced** — real Gmail `threadId` in `gmailThreadId`. Staff replies relay to Gmail via `handlers/messages.js``sendGmailReply`.
- **Discord-sourced** — `gmailThreadId` prefixed `discord-` / `discord-msg-`. No Gmail relay; conversation stays in Discord.
State machine:
```
(poll or /panel modal)
┌─────────────────┐
│ created │ — Ticket doc inserted; Discord channel (or thread) created under
│ (status: open, │ TICKET_CATEGORY_ID / DISCORD_TICKET_CATEGORY_ID (+overflow if full);
│ claimedBy: ∅) │ welcome embed + action row posted; role ping; optional pin; optional
└────────┬────────┘ staff thread; optional staff notification alerts
[Claim button] ───▶ claimedBy set; channel renamed via channelQueue (STAFF_EMOJIS prefix)
│ │
│ [Unclaim / auto-unclaim / claim-timeout] ──▶ back to unclaimed
[/escalate or Escalate button → T2 / T3]
│ Non-thread: enqueueMove → *_ESCALATED2/3_CHANNEL_ID category
│ Thread: skips category move (threads can't reparent)
│ Action: "unclaim" clears claim + resets unclaimedReminderssent; "keep" preserves
┌─────────────────┐
│ escalated │ escalationTier ∈ {2, 3}
└────────┬────────┘
[/deescalate] ──▶ step down one tier
[Close button → confirm_close → FORCE_CLOSE_TIMER grace]
┌─────────────────┐
│ closed │ transcript posted to TRANSCRIPT_CHANNEL_ID; closure email sent
│ (status: closed│ for email tickets; channel deleted (5s delay); Transcript doc written
└─────────────────┘
```
Orphan path: `reconcileDeletedTicketChannels` (60 min) finds open tickets whose Discord channel no longer exists and marks them closed.
## MongoDB collections (models.js)
All schemas live in a single file. Only indexes explicitly declared are listed; implicit `_id` and `unique: true` (which creates an index) are marked ✓.
| Collection | Key fields | Indexes | Notes |
|------------|------------|---------|-------|
| **Host** | `hostname`, `ip`, `region`, `status`, `memFree`, `cpuUsage`, `diskFree`, `lastSeen`, `lostInUse[]`, `statsHistory[]` | **none** | `lastSeen: { default: Date.now() }` — frozen at schema-definition time, bug (see P3) |
| **User** | `email`, `discordID`, `customerId`, `passwordHash`, `sessionToken`, `servers[]`, `subusers[]`, `activities[]` | **none** | 700+ lines, shared website schema. `email` / `discordID` queried in `handlers/accountinfo.js:47-54` without index |
| **DashboardMetrics** | `timestamp` (TTL 1yr), `activeUsers`, `workerId` | TTL ✓ | |
| **ErrorLog** | `timestamp` (TTL 30d), `statusCode`, `message`, `stack`, `url`, `method`, `userId`, `userEmail`, `authenticated`, `sessionValid` | TTL ✓ | |
| **Ticket** | `gmailThreadId` ✓ unique, `discordThreadId`, `senderEmail`, `subject`, `status` (`open`/`closed`), `priority`, `claimedBy` (display), `claimerId`, `ticketNumber`, `createdAt`, `lastActivity`, `escalated`, `escalationTier`, `welcomeMessageId`, `ticketTag`, `unclaimedReminderssent[]` *(typo preserved — see below)* | `gmailThreadId` unique ✓ | **`discordThreadId`, `claimedBy`, `status`, `ticketNumber`, `senderEmail` are all hot query fields with no index.** `unclaimedReminderssent` typo is load-bearing — preserved across `models.js:819`, `services/staffNotifications.js:85,111`, `handlers/commands.js:77` |
| **TicketCounter** | `senderLocal` ✓ unique, `counter` | ✓ | |
| **Transcript** | `gmailThreadId`, `transcriptMessageId`, `createdAt` | **none** | `gmailThreadId` queried in `gmail-poll.js:267` without index |
| **Tag** | `name` ✓ unique, `content`, `createdBy`, `useCount` | ✓ | Saved response templates |
| **CloseRequest** | `ticketId` ✓ unique, `requestedBy`, `reason` | ✓ | |
| **GuildSettings** | `guildId` ✓ unique, `emailRouting` (`thread`/`category`) | ✓ | |
| **StaffSettings** | `userId` ✓ unique, `guildId`, `notifyDm` | ✓ | |
| **StaffNotification** | `userId` ✓ unique, `guildId`, `channelId`, `cooldownHours` | ✓ | Per-staffer reply-alert channel |
| **StaffSignature** | `userId` ✓ unique, `guildId`, `valediction`, `displayName`, `tagline` | ✓ | |
## Express API route table
### `routes/bosscord.js` — mounted at `/api` after `ready`, only if `BOSSCORD_API_KEY` is set
| Method | Path | Auth | Input | Response |
|--------|------|------|-------|----------|
| GET | `/api/tickets` | Bearer | query: `status`, `priority`, `claimedBy`, `limit` (≤100) | `{ tickets: [...] }` |
| GET | `/api/me/tickets` | Bearer | header `X-Staff-Discord-Id` or query `claimedBy` | `{ tickets: [...] }` |
| GET | `/api/tickets/:id` | Bearer | path: ObjectId / ticketNumber / gmailThreadId | **raw ticket object** (inconsistent) |
| GET | `/api/tickets/:id/messages` | Bearer | query: `limit` (≤100) | `{ messages: [...] }` |
| POST | `/api/tickets/:id/messages` | Bearer | `{ content: string, displayName?: string }` | `{ ok: true }` (201) |
Middleware (applied once via `router.use`): `corsMiddleware` (`BOSSCORD_CORS_ORIGIN`, defaults to `*`) → `authMiddleware` (Bearer) → `requireDb`.
### `routes/internalApi.js` — `/internal/*` on a separate port (`INTERNAL_API_PORT`)
| Method | Path | Auth | Input | Response |
|--------|------|------|-------|----------|
| GET | `/internal/config` | `X-Internal-Secret` | — | `{ key: value, ... }` (redacted) |
| POST | `/internal/config` | `X-Internal-Secret` | `{ [key]: value }` | `{ applied: [...], errors: [...] }` |
| GET | `/internal/discord/guild` | `X-Internal-Secret` | — | `{ channels, roles, members, categories }` |
| POST | `/internal/restart` | `X-Internal-Secret` | `{ mode, scheduledFor? }`, modes: `immediate` / `scheduled` / `cancel_scheduled` / `pending` | `{ ok: true, mode, ... }` |
| GET | `/internal/restart/status` | `X-Internal-Secret` | — | `{ scheduledRestart: boolean }` |
## Environment variables
### Vars read in `config.js` but missing from `.env.example`
- `DISCORD_BOT_TOKEN` (alias for `DISCORD_TOKEN`)
- `HEALTHCHECK_HOST`
- `NOTIFICATION_THRESHOLDS_JSON`
- `ROLE_TO_PING_ID` (alias for `ROLE_ID_TO_PING`)
- `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS`
- `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS`
- `NODE_ENV`, `ENV_FILE` (implicit)
### Vars in `.env.example` but not read via `config.js`
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` — read directly by `services/gmail.js` and `broccolini-discord.js`, not via `CONFIG`
- `MONGODB_URI` — read directly by `broccolini-discord.js:99` and `scripts/test-mongodb.js`, not via `CONFIG`
- `NGROK_URL` — unused
- `DISCORD_ESCALATED_CATEGORY_ID`, `EMAIL_ESCALATED_CATEGORY_ID` — legacy names, superseded by `*_ESCALATED2/3_CHANNEL_ID`
### Key env categories (see `.env.example` for the full list)
| Category | Vars |
|----------|------|
| Discord core | `DISCORD_TOKEN`, `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID`, `TICKET_CATEGORY_ID`, `DISCORD_TICKET_CATEGORY_ID`, `*_OVERFLOW_CATEGORY_IDS`, `ROLE_ID_TO_PING`, `TRANSCRIPT_CHANNEL_ID`, `LOGGING_CHANNEL_ID`, `DEBUGGING_CHANNEL_ID` |
| Escalation | `EMAIL_ESCALATED2/3_CHANNEL_ID`, `DISCORD_ESCALATED2/3_CHANNEL_ID` |
| Staff notifications | `STAFF_NOTIFICATION_CATEGORY_ID`, `STAFF_EMOJIS`, `CLAIMER_EMOJI_FALLBACK`, `ADMIN_ID`, `UNCLAIMED_REMINDER_THRESHOLDS` |
| Gmail | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `REFRESH_TOKEN`, `MY_EMAIL`, `GMAIL_POLL_INTERVAL_MS` |
| MongoDB | `MONGODB_URI` |
| HTTP | `DISCORD_ONLY_PORT`/`PORT`, `HEALTHCHECK_HOST`, `BOSSCORD_API_KEY`, `BOSSCORD_CORS_ORIGIN`, `INTERNAL_API_PORT`, `INTERNAL_API_SECRET` |
| Automation | `AUTO_CLOSE_*`, `REMINDER_*`, `AUTO_UNCLAIM_*`, `CLAIM_TIMEOUT_*`, `FORCE_CLOSE_TIMER` |
| Rate limits | `GLOBAL_TICKET_LIMIT`, `TICKET_LIMIT_PER_CATEGORY`, `RATE_LIMIT_*` |
| Patterns | `PATTERN_*_THRESHOLD`, `*_PATTERNS_CHANNEL_ID` |
| Surge | `SURGE_*`, `ALL_STAFF_CHANNEL_ID`, `SURGE_ROLE_ID`, `STAFF_IDS` |
| Chat alerts | `CHAT_ALERT_CHANNEL_IDS`, `ALL_STAFF_CHAT_ALERT_CHANNEL_ID`, `CHAT_ALERT_*` |
| Branding | `SUPPORT_NAME`, `LOGO_URL`, `SIGNATURE`, `TICKET_WELCOME_MESSAGE`, `TICKET_CLAIMED_MESSAGE`, `ESCALATION_MESSAGE`, embed colors |
## Key patterns
### Channel queue
`services/channelQueue.js` serializes **renames** (`enqueueRename`) and **moves** (`enqueueMove`). Discord caps renames at 2 per 10 min per channel; the queue emits a relative-time message in the channel when blocked. **Rule:** any code that changes a channel's name or parent must use these helpers. `handlers/commands.js:540` (`/move`) currently bypasses this with a direct `setParent` — see P1 prompt.
### Logging
`services/debugLog.js` is fire-and-forget: every log helper returns a promise and callers attach `.catch(() => {})`. Rule: never `await` logging on a hot path. Channels are selected by the `*_LOG_CHANNEL_ID` env vars (`GMAIL_LOG_CHANNEL_ID`, `AUTOMATION_LOG_CHANNEL_ID`, `RENAME_LOG_CHANNEL_ID`, `SECURITY_LOG_CHANNEL_ID`, `SYSTEM_LOG_CHANNEL_ID`, `DEBUGGING_CHANNEL_ID`).
### Staff detection
Staff = members with `ROLE_ID_TO_PING` or any role in `ADDITIONAL_STAFF_ROLES`. `ADMIN_ID` is a single-user gate for `/staffnotification`. `STAFF_IDS` drives surge "no staff available" calculations with `STAFF_DND_COUNTS_AS_AVAILABLE` as a tiebreaker.
### Claim identity
`Ticket.claimedBy` is a display label (string), `Ticket.claimerId` is the Discord user ID. Channel-name emoji comes from `STAFF_EMOJIS` (`userId:emoji,...`) with `CLAIMER_EMOJI_FALLBACK`.
### Pattern/counter store
`services/patternStore.js` holds in-memory counters keyed by namespace + window (`today`/`week`/`month`) with auto-reset timers from `scheduleResets()`. Not persisted — resets on process restart.
### Deprecated
`services/staffChannel.js` and the `STAFF_CATEGORIES` map are legacy. `STAFF_CATEGORIES` is empty in current `config.js`, `createStaffChannel` is not called from the claim flow, and `Ticket.staffChannelId` is effectively unused. Reply alerts instead flow through `StaffNotification` channels (`/notification add`).
## Known issues (root causes documented; NO fix prompts)
1. **Gmail `invalid_grant`**`gmail-poll.js:351-372`. Polling catches auth errors (`invalid_grant` / `unauthorized` / `Invalid Credentials` / HTTP 401), logs via `logError('Gmail OAuth', …)`, DMs `ADMIN_ID` **once** (`authErrorNotified` flag), and silently no-ops subsequent polls. By design — requires manual `REFRESH_TOKEN` refresh via `node get-refresh-token.js`. The surrounding bot and bOSScord API continue to function.
2. **`STAFF_EMOJIS` encoding** — `config.js` parses `userId:emoji` pairs from env; some custom emojis render as mojibake in channel names. Root cause not yet identified; likely interaction between `.env` file encoding (UTF-8 vs BOM), `dotenv-expand` handling, and Discord's custom emoji syntax (`<:name:id>`) vs Unicode codepoints. Needs a targeted trace through `config.js` parsing.
3. **Escalation button**`handlers/buttons.js` handlers for `escalate_to_tier2` / `escalate_to_tier3`. Reports of the handler "not firing reliably." Root cause not yet identified. Candidate areas: interaction deferral timing (3 s rule), missing `return` between button branches in the dispatcher, or `enqueueMove` back-pressure when the target category is full and the handler errors before replying.
---
# Improvement prompts
Each prompt follows CLAUDE.md's format. Prompts intended for OpenCode to execute. None of the known issues above appear here.
---
## P0 — Fix undefined vars in ticket-closure email body
**Priority:** P0 (broken)
**Files:** `/opt/broccolini-bot/services/gmail.js` (lines 108129), `/opt/broccolini-bot/config.js` (to confirm `TICKET_CLOSE_MESSAGE` / signature vars)
**Problem:** `sendTicketClosedEmail` references `safeCloseMessage` and `safeCloseSignature` on lines 115116 of the HTML body, but neither variable is defined anywhere in the function. Every closure email sent for an email-sourced ticket currently contains literal `undefined` text in both the message paragraph and the signature line, which customers see. This has been broken for an unknown period because nothing tests closure email rendering.
**Fix:**
1. Read the full `sendTicketClosedEmail` function (surrounding ~50 lines) to confirm the escape pattern used by `safeReply` / `safeLogoUrl` / `safeSignature`.
2. Immediately after line 110 (where `safeSignature` is computed), add:
```js
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || CONFIG.DISCORD_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
const safeCloseSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
```
— adjust the CONFIG key to whichever close-message var actually exists (`CONFIG.TICKET_CLOSE_MESSAGE` is the most likely name; fall back to the existing `DISCORD_CLOSE_MESSAGE` if not present). Do not invent a new env var.
3. Do not modify the HTML template structure.
**Verify:**
- Trigger a close on a throwaway **email-sourced** ticket in the test environment.
- Inspect the resulting Gmail message (the customer-bound send) and confirm the `<p>` that previously said `undefined` now contains the configured close message, and the signature block below it renders correctly.
- If no test env exists for Gmail, at minimum console-log `htmlBody` once and grep for `undefined`.
---
## P1 — Route `/move` through `enqueueMove` instead of direct `setParent`
**Priority:** P1 (channel queue bypass — CLAUDE.md Hard Rule #3)
**Files:** `/opt/broccolini-bot/handlers/commands.js` (around line 540), `/opt/broccolini-bot/services/channelQueue.js`
**Problem:** The `/move` slash handler calls `await interaction.channel.setParent(category.id, { lockPermissions: true })` directly. Every other category move in the codebase flows through `services/channelQueue.js`'s `enqueueMove`, which serializes moves and logs via the rename channel. Direct `setParent` skips that serialization and, more importantly, skips the rate-limit / error handling the queue provides.
**Fix:**
1. At the top of `handlers/commands.js`, confirm `enqueueMove` is imported from `../services/channelQueue`. Add the import if missing.
2. Replace line 540 with `await enqueueMove(interaction.channel, category.id);`
3. Confirm `enqueueMove` preserves `lockPermissions: true` behavior (read `services/channelQueue.js:~95`). If it does not, add a `lockPermissions` option to `enqueueMove` (defaulting to `true` to match existing callers), rather than reverting `/move` to a direct call.
4. Leave the surrounding `interaction.reply` / log-channel send untouched.
**Verify:**
- Run `/move` in a test ticket channel targeting another category. Confirm it moves.
- Run two `/move` commands back-to-back from different ticket channels. Confirm both complete without rate-limit errors and both appear in `RENAME_LOG_CHANNEL_ID` (if the queue logs moves there).
---
## P1 — Validate and bound `content` on `POST /api/tickets/:id/messages`
**Priority:** P1 (input validation / security boundary)
**Files:** `/opt/broccolini-bot/routes/bosscord.js` (lines 159223)
**Problem:** The endpoint accepts an arbitrary `content` string with only a type check (`typeof content !== 'string'`). There is no length cap, no whitespace check, and `req.body.displayName` is piped into `sendGmailReply` as `discordUser` without validation. A client bug or malicious caller can post a 10 MB string to Discord (which will error partway through but only after a `channel.send` attempt) or inject arbitrary display names into outbound email. Discord's own cap is 2000 chars per message.
**Fix:**
1. After the existing `content` type check (line 169), add:
```js
const trimmed = content.trim();
if (!trimmed) return res.status(400).json({ error: 'content is empty' });
if (trimmed.length > 2000) return res.status(400).json({ error: 'content exceeds 2000 characters' });
```
Use `trimmed` for the rest of the handler.
2. Validate `displayName`: coerce to string, trim, cap at 80 chars, and replace anything outside `[\w \-.']` with empty string. If the result is empty, fall back to `'bOSScord'`. Do not echo unvalidated user input into the outbound email header.
3. Do not change the response shape.
**Verify:**
- `curl` the endpoint with a 3000-char body and confirm a 400 response.
- `curl` with `{"content":"hi","displayName":"<script>alert(1)</script>"}` and confirm the email (if sent) shows a sanitized display name.
- `curl` with a normal `{"content":"test"}` and confirm the existing happy path still returns `{ok: true}` and delivers to Discord.
---
## P1 — Add hot-path indexes to `Ticket`
**Priority:** P1 (data layer / performance and correctness under load)
**Files:** `/opt/broccolini-bot/models.js` (the `ticketSchema` block ~lines 795821)
**Problem:** Only `gmailThreadId` is indexed on `Ticket`. The live query hotspots are `discordThreadId` (every `messageCreate` does a `findOne` on it — see `handlers/messages.js`), `claimedBy` + `status` (the bOSScord `/api/me/tickets` filter), `status` alone (unclaimed-reminder job scans it every 30 min), and `senderEmail` + `ticketNumber` (search commands). As the collection grows, these turn into full-collection scans on every Discord message.
**Fix:** Inside the `ticketSchema` definition (not inline on the field — use `ticketSchema.index(...)` calls at the end of the schema block so it's obvious what the indexes are):
```js
ticketSchema.index({ discordThreadId: 1 }, { unique: true, sparse: true });
ticketSchema.index({ status: 1, claimedBy: 1 });
ticketSchema.index({ status: 1, lastActivity: -1 });
ticketSchema.index({ senderEmail: 1, createdAt: -1 });
ticketSchema.index({ ticketNumber: 1 });
```
`discordThreadId` should be `unique, sparse` because Discord-only tickets set it immediately, email tickets may briefly lack it during creation, and no two tickets should share a channel. Confirm the sparse-unique behavior doesn't conflict with existing data before enabling (see Verify).
**Verify:**
- Before deploy, run `db.tickets.aggregate([{$group: {_id: "$discordThreadId", c: {$sum: 1}}}, {$match: {c: {$gt: 1}}}])` against `broccoli_db` to confirm no duplicate `discordThreadId` values exist. If any do, investigate (they indicate prior orphaning bugs) before adding the unique index.
- After redeploy, run `db.tickets.getIndexes()` via mongosh and confirm all five new indexes exist.
- Spot-check with `db.tickets.find({discordThreadId: "<some id>"}).explain("executionStats")` — should show `IXSCAN`, not `COLLSCAN`.
---
## P1 — Add index on `Transcript.gmailThreadId`
**Priority:** P1
**Files:** `/opt/broccolini-bot/models.js` (`transcriptSchema`, ~lines 828832)
**Problem:** `gmail-poll.js:267` queries `Transcript.findOne({ gmailThreadId })` on every inbound email that might be a reopen, with no index.
**Fix:** Append `transcriptSchema.index({ gmailThreadId: 1 });` to the schema definition block.
**Verify:** `db.transcripts.getIndexes()` shows the new index; `db.transcripts.find({gmailThreadId: "<id>"}).explain("executionStats")` is `IXSCAN`.
---
## P1 — Validate `/internal/config` POST body against an allowlist
**Priority:** P1 (admin API; wide blast radius)
**Files:** `/opt/broccolini-bot/routes/internalApi.js` (~lines 2939), `/opt/broccolini-bot/config.js` (to derive the allowlist)
**Problem:** `POST /internal/config` forwards the request body to `applyConfigUpdates()` with only a type check (`typeof body === 'object'`). Any caller with `INTERNAL_API_SECRET` can set arbitrary keys. An attacker who exfiltrates the secret can poison `CONFIG` with unknown keys that silently shadow code reads.
**Fix:**
1. Build a module-level `const ALLOWED_CONFIG_KEYS = new Set([...])` containing every key defined in `config.js`. Generate this by reading `config.js`; do not hand-type it. If `config.js` exports the list (or can cheaply derive it from `Object.keys(CONFIG)`), prefer that.
2. At the top of the POST handler, iterate `Object.keys(req.body)` and collect any not in `ALLOWED_CONFIG_KEYS`. If any exist, return 400 with `{ error: 'Unknown config keys', rejected: [...] }`.
3. Do not change successful-path behavior.
**Verify:**
- `curl -H "x-internal-secret: $S" -H 'content-type: application/json' -d '{"TICKET_CATEGORY_ID":"123"}' .../internal/config` — still works.
- `curl ... -d '{"NOT_A_REAL_KEY":"x"}' ...` — returns 400 with the rejected key listed.
---
## P1 — Validate `scheduledFor` on `/internal/restart`
**Priority:** P1
**Files:** `/opt/broccolini-bot/routes/internalApi.js` (~lines 87123)
**Problem:** `POST /internal/restart` passes `scheduledFor` to `new Date()` without format checks. Invalid strings become `Invalid Date`, past timestamps schedule in the past (immediate restart), and there is no upper bound on how far in the future a restart can be scheduled.
**Fix:** When `mode === 'scheduled'`:
1. Require `scheduledFor` to be a string matching ISO-8601 (`Date.parse` returning a finite number is sufficient).
2. Reject if `Number.isNaN(parsed)` — return 400 `{ error: 'scheduledFor must be a valid ISO-8601 timestamp' }`.
3. Reject if the timestamp is in the past or more than 24 hours in the future — return 400.
**Verify:** POST with `{mode:"scheduled", scheduledFor:"not-a-date"}` returns 400. POST with a timestamp 2 min in the future succeeds. POST with a timestamp 1 week in the future returns 400.
---
## P2 — Fix unsafe async IIFE in force-close cleanup
**Priority:** P2 (silent error swallowing; reliability)
**Files:** `/opt/broccolini-bot/handlers/buttons.js` (lines ~595605)
**Problem:** After channel deletion on force-close, a `setTimeout` wraps an async IIFE that calls `cleanupEmptyOverflowCategory(...)` without a `.catch`. A thrown error from that cleanup is an unhandled rejection that the global handler logs but no one sees per-ticket, and the force-close flow appears successful even when cleanup failed.
**Fix:** Replace the IIFE with:
```js
setTimeout(() => {
cleanupEmptyOverflowCategory(/* same args */)
.catch((err) => logError('cleanupEmptyOverflowCategory', err).catch(() => {}));
}, 6000);
```
(Do not `await` the `logError` call — logging is fire-and-forget per CLAUDE.md Hard Rule #4.)
**Verify:** Force-close a ticket in an overflow category with the cleanup function temporarily throwing. Confirm the error surfaces in the debug channel instead of only the global `unhandledRejection` log.
---
## P2 — Normalize `/api/tickets/:id` response shape
**Priority:** P2 (API contract — **coordinate with bOSScord**)
**Files:** `/opt/broccolini-bot/routes/bosscord.js` (lines 106119), plus bOSScord client code (out of tree)
**Problem:** `/api/tickets` returns `{ tickets: [...] }`, `/api/me/tickets` returns `{ tickets: [...] }`, `/api/tickets/:id/messages` returns `{ messages: [...] }`, but `/api/tickets/:id` returns the raw ticket object. bOSScord has to handle two shapes. CLAUDE.md warns that response-shape changes will break bOSScord.
**Fix:** This is a **coordinated change**. Do not modify `routes/bosscord.js` in isolation. Instead:
1. Open this as a doc-only prompt first: add a note to `docs/api/` (create the file if needed) listing the current shapes and marking the single-ticket endpoint as "wrapped in `{ ticket }` in vNext — bOSScord must be updated in lockstep."
2. Separately, coordinate with the bOSScord repo. Once bOSScord is updated, a follow-up prompt will change line 114 from `res.json(out)` to `res.json({ ticket: out })`.
**Verify (for the doc-only step):** `docs/api/bosscord.md` exists and accurately describes the five endpoints' current and target shapes.
---
## P2 — Audit long-running slash commands for deferReply
**Priority:** P2 (Discord.js best practices)
**Files:** `/opt/broccolini-bot/handlers/commands.js` (read-only audit), `/opt/broccolini-bot/handlers/buttons.js`
**Problem:** Discord requires an interaction response (reply or defer) within 3 seconds. Any command that fetches from Mongo + makes multiple Discord API calls + possibly calls Gmail is at risk. `/escalate` (queue move + channel rename + log send + email?), `/move`, `/transfer`, `/backup`, `/export` are candidates.
**Fix:**
1. **Read-only first:** grep `handlers/commands.js` for each of `/escalate`, `/deescalate`, `/move`, `/transfer`, `/backup`, `/export`, `/search`, `/history`, `/gmailpoll check`, and identify the first user-visible response on each path.
2. For any command where the first `interaction.reply` / `interaction.editReply` happens after two or more awaited calls, add `await interaction.deferReply({ ephemeral: <matching existing ephemerality> });` as the very first action, and convert subsequent `interaction.reply` calls on that path to `interaction.editReply` or `interaction.followUp`.
3. Do not touch commands that already defer.
**Verify:**
- Run `/backup` and `/export` on a server with 100+ tickets. Confirm no `InteractionAlreadyReplied` or `Unknown interaction` errors in console.
- Run `/escalate` and confirm the loading state appears immediately, then resolves.
---
## P2 — Add try/catch around `handleDiscordReply`
**Priority:** P2
**Files:** `/opt/broccolini-bot/broccolini-discord.js` (messageCreate listener, ~lines 159170)
**Problem:** `handleDiscordReply(msg)` is called inside the `messageCreate` listener without explicit error handling. Any rejection (Gmail send failure, Mongo write error) becomes an `unhandledRejection` that the global handler logs but without message/channel context.
**Fix:** Wrap the call:
```js
handleDiscordReply(msg).catch((err) =>
logError('handleDiscordReply', err, null).catch(() => {})
);
```
Do not `await` — the event listener should not block on relay.
**Verify:** Throw a test error inside `handleDiscordReply` once; confirm the debug channel shows the error with the `handleDiscordReply` context label, not `unhandledRejection`.
---
## P2 — Sweep for token leakage in error logs
**Priority:** P2 (defense in depth)
**Files:** `/opt/broccolini-bot/services/gmail.js`, `/opt/broccolini-bot/gmail-poll.js`, `/opt/broccolini-bot/routes/bosscord.js`, `/opt/broccolini-bot/routes/internalApi.js`, `/opt/broccolini-bot/services/debugLog.js`
**Problem:** `logError(ctx, err)` forwards `err.stack` and `err.message` to a Discord channel. OAuth 401 responses from googleapis sometimes include the bearer token or refresh token in the error object's `config.headers.Authorization`. The bOSScord auth middleware sees raw `Authorization` headers. There is no active sanitization on the way to the log channel.
**Fix:**
1. **Audit:** read `services/debugLog.js:logError` and confirm exactly what fields of `err` get embedded in the Discord embed.
2. If `err.config` or `err.response.config.headers` are interpolated, add a sanitize step that strips `Authorization`, `refresh_token`, `access_token`, and any key matching `/token|secret|password/i` from the logged object before calling `.send`.
3. If only `err.message` and `err.stack` are logged, grep those for `process.env.REFRESH_TOKEN`, `process.env.BOSSCORD_API_KEY`, `process.env.INTERNAL_API_SECRET` literally — if the values appear, redact them before posting.
**Verify:** Force a Gmail 401 (e.g., in test env with a deliberately invalid token) and confirm the debug-channel log does not contain the refresh token string.
---
## P3 — Fix `Host.lastSeen` default (frozen at schema-definition time)
**Priority:** P3
**Files:** `/opt/broccolini-bot/models.js` (Host schema, around the `lastSeen` field)
**Problem:** `lastSeen: { type: Number, default: Date.now() }` — `Date.now()` is **called once** when the schema is defined at process start. Every new `Host` document gets the same timestamp (process start time) as the default, not the creation time.
**Fix:** Change to `default: Date.now` (pass the function reference) or `default: () => Date.now()`. No behavior change for existing docs.
**Verify:** `new Host({hostname:'x'}).save()` twice across a few seconds; confirm the two documents have different `lastSeen` values.
---
## P3 — Remove unused `p-queue` dependency
**Priority:** P3
**Files:** `/opt/broccolini-bot/package.json`, `/opt/broccolini-bot/package-lock.json`
**Problem:** `p-queue@^6.6.2` is declared in `dependencies` but never `require`d anywhere in the codebase (the channel queue implements its own serialization). Dead dependency bloats the install and the supply-chain surface.
**Fix:** `npm uninstall p-queue`. Commit both `package.json` and `package-lock.json`.
**Verify:** `grep -r "p-queue" .` returns no results outside `node_modules`. `npm ls` does not list it. Bot starts cleanly.
---
## P3 — Mark `services/staffChannel.js` as deprecated (or delete)
**Priority:** P3
**Files:** `/opt/broccolini-bot/services/staffChannel.js`, `/opt/broccolini-bot/models.js` (`Ticket.staffChannelId`)
**Problem:** `STAFF_CATEGORIES` is empty in `config.js`, `createStaffChannel` is not called from the claim flow, `Ticket.staffChannelId` is never read. The file still exports four functions that could mislead a reader into thinking the mirror-channel pattern is active.
**Fix:**
1. First verify: grep the repo for `staffChannel`, `createStaffChannel`, `staffChannelId`. Confirm the only matches are definitions + legacy doc references.
2. If truly unreferenced: add a file-top comment `// DEPRECATED: legacy per-staffer mirror channels. Not used in the current claim flow. Kept for history — do not reintroduce.` Leave the code in place to avoid git-history loss. Do **not** delete `Ticket.staffChannelId` (old tickets may have the field).
3. If any active caller exists (unexpected), stop and report the finding — do not modify.
**Verify:** After the comment is added, bot starts cleanly. `grep -r staffChannelId handlers services routes` shows no runtime read-sites.
---
## P3 — Reconcile `.env.example` with `config.js`
**Priority:** P3 (documentation hygiene)
**Files:** `/opt/broccolini-bot/.env.example`, `/opt/broccolini-bot/config.js`
**Problem:** 8 vars are read in code but not documented; 6 are documented but never read. New operators hit both problems on day one.
**Fix:**
1. **Add to `.env.example`** (as commented entries with one-line descriptions): `HEALTHCHECK_HOST`, `NOTIFICATION_THRESHOLDS_JSON`, `ROLE_TO_PING_ID` (as alias note on the existing `ROLE_ID_TO_PING`), `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS`, `DISCORD_TICKET_OVERFLOW_CATEGORY_IDS`. `DISCORD_BOT_TOKEN` should be added as an explicit alias comment under `DISCORD_TOKEN`.
2. **Remove from `.env.example`**: `NGROK_URL` (unused), `DISCORD_ESCALATED_CATEGORY_ID`, `EMAIL_ESCALATED_CATEGORY_ID` (legacy; superseded by `*_ESCALATED2/3_CHANNEL_ID`).
3. Do **not** move `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, or `MONGODB_URI` — they are read directly (not via `CONFIG`) and should stay in `.env.example`.
**Verify:** Diff `.env.example` against `Object.keys(require('./config').CONFIG)` plus the three directly-read vars. No gaps either way.
---
## P3 — CVE sweep on top-level dependencies
**Priority:** P3 (read-only audit)
**Files:** `/opt/broccolini-bot/package.json`, `/opt/broccolini-bot/package-lock.json`
**Problem:** `mongoose@^6.12.0` is a generation behind (v7/v8 shipped), `express@^5.2.1` is early in the v5 line, `googleapis@^171.x` ships frequently with transitive fixes. No active `npm audit` output is documented.
**Fix (read-only):** Run `npm audit --omit=dev --json` at the repo root and paste the result into a new `docs/audit/npm-audit-YYYY-MM-DD.md`. Do not auto-upgrade. Flag any `high` / `critical` findings separately so they can be triaged individually.
**Verify:** The audit file exists and lists each finding with CVE ID, affected package, and fix version. No package.json changes in this prompt.
---
# End of improvement prompts
Total: 1 P0, 7 P1, 5 P2, 5 P3 — 18 prompts. Three known issues deliberately excluded (Gmail `invalid_grant`, `STAFF_EMOJIS` encoding, escalation button).

View File

@@ -12,7 +12,7 @@ const {
ContextMenuCommandBuilder,
ApplicationCommandType
} = require('discord.js');
const { CONFIG, TICKET_TAGS } = require('../config');
const { CONFIG } = require('../config');
async function registerCommands() {
if (!CONFIG.CLIENT_ID || !CONFIG.DISCORD_GUILD_ID) return;
@@ -22,7 +22,7 @@ async function registerCommands() {
const commands = [
new SlashCommandBuilder()
.setName('escalate')
.setDescription('Escalate this ticket to tier 2 or tier 3')
.setDescription('Escalate this ticket to tier 2 or tier 3 (always unclaims)')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
@@ -35,16 +35,6 @@ async function registerCommands() {
{ name: 'Tier 2', value: '2' },
{ name: 'Tier 3', value: '3' }
)
)
.addStringOption(opt =>
opt
.setName('action')
.setDescription('Unclaim ticket or keep current claimer')
.setRequired(true)
.addChoices(
{ name: 'Unclaim', value: 'unclaim' },
{ name: 'Keep', value: 'keep' }
)
),
new SlashCommandBuilder()
@@ -128,19 +118,6 @@ async function registerCommands() {
.setRequired(true)
),
new SlashCommandBuilder()
.setName('tag')
.setDescription('Set ticket category (dropdown)')
.setContexts([InteractionContextType.Guild])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(o =>
o
.setName('category')
.setDescription('Ticket category tag')
.setRequired(true)
.addChoices(...(TICKET_TAGS || []).map(({ value, emoji, name }) => ({ name: `${emoji} ${name}`, value })))
),
new SlashCommandBuilder()
.setName('response')
.setDescription('Saved response tags (custom templates)')
@@ -228,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')
@@ -276,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.')
@@ -300,136 +263,6 @@ async function registerCommands() {
)
),
new SlashCommandBuilder()
.setName('backup')
.setDescription('Export full ticket list to a .txt file in the backup/export channel')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
new SlashCommandBuilder()
.setName('export')
.setDescription('Export tickets (optional filter and limit) to a .txt file in the backup/export channel')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addStringOption(opt =>
opt
.setName('status')
.setDescription('Filter by status')
.setRequired(false)
.addChoices(
{ name: 'Open', value: 'open' },
{ name: 'Closed', value: 'closed' }
)
)
.addIntegerOption(opt =>
opt
.setName('limit')
.setDescription('Max number of tickets to export (default 500)')
.setMinValue(1)
.setMaxValue(5000)
.setRequired(false)
),
new SlashCommandBuilder()
.setName('priority')
.setDescription('Set the priority of this ticket')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('level')
.setDescription('Priority level')
.setRequired(true)
.addChoices(
{ name: '🟢 Low', value: 'low' },
{ name: '🟡 Normal', value: 'normal' },
{ name: '🟠 Medium', value: 'medium' },
{ name: '🔴 High', value: 'high' }
)
),
new SlashCommandBuilder()
.setName('search')
.setDescription('Search for tickets')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addStringOption(opt =>
opt
.setName('query')
.setDescription('Search query (email, subject, or ticket number)')
.setMinLength(2)
.setMaxLength(100)
.setRequired(true)
)
.addStringOption(opt =>
opt
.setName('status')
.setDescription('Filter by status')
.setRequired(false)
.addChoices(
{ name: 'Open', value: 'open' },
{ name: 'Closed', value: 'closed' },
{ name: 'All', value: 'all' }
)
),
new SlashCommandBuilder()
.setName('stats')
.setDescription('View bot statistics and analytics')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
new SlashCommandBuilder()
.setName('notification')
.setDescription('Manage your staff notification settings')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addSubcommand(sub =>
sub
.setName('set')
.setDescription('Set your notification cooldown (hours between alerts per ticket)')
.addIntegerOption(opt =>
opt
.setName('hours')
.setDescription('Cooldown in hours (16)')
.setMinValue(1)
.setMaxValue(6)
.setRequired(true)
)
)
.addSubcommand(sub =>
sub
.setName('add')
.setDescription('Create a notification channel for a staff member')
.addUserOption(opt =>
opt.setName('member').setDescription('Staff member').setRequired(true)
)
),
new SlashCommandBuilder()
.setName('staffnotification')
.setDescription('Override notification cooldown for another staff member (admin only)')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addUserOption(opt =>
opt.setName('member').setDescription('Staff member').setRequired(true)
)
.addIntegerOption(opt =>
opt
.setName('hours')
.setDescription('Cooldown in hours (16)')
.setMinValue(1)
.setMaxValue(6)
.setRequired(true)
),
new SlashCommandBuilder()
.setName('closetimer')
.setDescription('Set the force-close countdown duration')
@@ -524,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' },
@@ -544,38 +375,6 @@ async function registerCommands() {
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
new SlashCommandBuilder()
.setName('fix-stale-tickets')
.setDescription('Admin only: backfill lastActivity on open tickets where it is null (sets to createdAt).')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
new SlashCommandBuilder()
.setName('accountinfo')
.setDescription('Look up website account info by email or Discord user')
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.addSubcommand(sub =>
sub
.setName('email')
.setDescription('Look up by email address')
.addStringOption(opt =>
opt.setName('email').setDescription('Account email').setRequired(true)
)
)
.addSubcommand(sub =>
sub
.setName('discord')
.setDescription('Look up by Discord user')
.addUserOption(opt =>
opt.setName('user').setDescription('Discord user').setRequired(true)
)
),
new SlashCommandBuilder()
.setName('signature')
.setDescription('Set your personal email signature (valediction, display name, tagline)')

253
config.js
View File

@@ -1,152 +1,37 @@
/**
* 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.
*
* Test env: set ENV_FILE=.env.test to load .env.test instead of .env (see ENV_AND_SECURITY.md).
* Never commit .env or .env.test; agents must not modify .env without explicit user confirmation.
* 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 envPath = process.env.ENV_FILE
? path.resolve(process.cwd(), process.env.ENV_FILE)
: undefined;
let parsed = dotenv.config({ path: envPath, debug: process.env.NODE_ENV === 'development' });
if (envPath && parsed.error) {
console.warn(`[config] ENV_FILE=${process.env.ENV_FILE} not found or unreadable:`, parsed.error.message);
}
dotenvExpand.expand(parsed);
// If no ENV_FILE, also load repo root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env)
if (!envPath) {
const rootEnv = path.resolve(process.cwd(), '..', '.env');
const rootParsed = dotenv.config({ path: rootEnv });
if (!rootParsed.error && rootParsed.parsed) {
for (const [k, v] of Object.entries(rootParsed.parsed)) {
if (v != null && String(v).trim() !== '') process.env[k] = v;
}
dotenvExpand.expand(rootParsed);
}
}
const DEFAULT_NOTIFICATION_THRESHOLDS = {
// patternChecker - age-based (time since condition first became true)
user_tickets: ['15m', '30m', '1h', '3h'],
user_reopen: ['1h', '4h', '1d'],
user_crossgame: ['1h', '1d'],
game_surge: ['15m', '30m', '1h'],
game_backlog: ['30m', '1h', '3h', '6h'],
game_resolution: ['1d'],
game_spike: ['15m', '30m'],
tag_top: ['1h', '6h', '1d'],
tag_escalation: ['1h', '6h', '1d'],
untagged_closes: ['1h', '1d'],
tag_game_corr: ['1d'],
user_esc: ['1h', '6h', '1d'],
game_esc_rate: ['1d'],
rapid_t2_t3: ['3', '5', '10', '15', '20', '30', '50'], // count-based milestones, not time
staff_no_close: ['1h', '3h'],
staff_overloaded: ['1h', '3h', '6h'],
staff_stale: ['1h', '3h'],
staff_transfer_rate: ['1h', '1d'],
staff_esc: ['1h', '6h', '1d'],
staff_game_esc: ['1d'],
game_tag_spike: ['1h', '6h'],
overnight_gap: ['1d'],
staff_always_esc: ['1d'],
// surgeChecker - cooldown-escalating (repeat alerts spaced further apart)
surge_tickets: ['10m', '30m', '1h', '2h', '3h'],
surge_game: ['10m', '30m', '1h', '2h'],
surge_stale: ['30m', '1h', '2h', '4h'],
surge_needs_response: ['15m', '30m', '1h', '3h'],
surge_unclaimed: ['15m', '30m', '1h', '2h', '4h'],
surge_tier3_unclaimed: ['10m', '15m', '30m', '1h', '2h'],
surge_no_staff: ['10m', '20m', '30m', '1h'],
// staffNotifications - age-based per ticket (hours)
unclaimed_reminder: ['1h', '2h', '4h', '8h', '1d'],
// chatAlertChecker - cooldown-escalating
chat_messages: ['15m', '30m', '1h', '3h'],
chat_time: ['30m', '1h', '2h', '4h']
};
require('dotenv').config({ debug: process.env.NODE_ENV === 'development' });
function toInt(v, fallback) {
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : fallback;
}
function parseThresholdString(str) {
const value = String(str || '').trim();
if (!value) return NaN;
// Integers without a unit are raw count milestones.
if (/^\d+$/.test(value)) return parseInt(value, 10);
let totalMs = 0;
const re = /(\d+)([mhd])/g;
let match;
let consumed = '';
while ((match = re.exec(value)) !== null) {
const amount = parseInt(match[1], 10);
const unit = match[2];
consumed += match[0];
if (unit === 'm') totalMs += amount * 60 * 1000;
else if (unit === 'h') totalMs += amount * 60 * 60 * 1000;
else if (unit === 'd') totalMs += amount * 24 * 60 * 60 * 1000;
}
if (!consumed || consumed !== value) return NaN;
return totalMs;
}
function parseNotificationThresholdsJson(raw) {
if (!raw || !String(raw).trim()) return DEFAULT_NOTIFICATION_THRESHOLDS;
try {
const parsedJson = JSON.parse(raw);
if (parsedJson && typeof parsedJson === 'object' && !Array.isArray(parsedJson)) {
return parsedJson;
}
} catch (err) {
console.warn('[config] Failed to parse NOTIFICATION_THRESHOLDS_JSON, using default:', err.message);
}
return DEFAULT_NOTIFICATION_THRESHOLDS;
}
const CONFIG = {
DISCORD_TOKEN: (process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || '').trim(),
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
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,
BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null,
ACCOUNT_INFO_CHANNEL_ID: process.env.ACCOUNT_INFO_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, '<br>'),
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,
@@ -163,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),
@@ -181,13 +64,9 @@ const CONFIG = {
PRIORITY_HIGH_EMOJI: process.env.PRIORITY_HIGH_EMOJI || '🔴',
PRIORITY_MEDIUM_EMOJI: process.env.PRIORITY_MEDIUM_EMOJI || '🟡',
PRIORITY_LOW_EMOJI: process.env.PRIORITY_LOW_EMOJI || '🟢',
CLAIM_TIMEOUT_ENABLED: process.env.CLAIM_TIMEOUT_ENABLED === 'true',
CLAIM_TIMEOUT_HOURS: toInt(process.env.CLAIM_TIMEOUT_HOURS, 48),
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',
@@ -195,72 +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),
STAFF_CATEGORIES: new Map(), // deprecated kept for staffChannel.js compat
STAFF_EMOJIS: (() => {
const raw = process.env.STAFF_EMOJIS;
const map = new Map();
if (!raw || !String(raw).trim()) return map;
for (const part of String(raw).split(',')) {
const seg = part.trim();
if (!seg) continue;
const idx = seg.indexOf(':');
if (idx === -1) continue;
const userId = seg.slice(0, idx).trim();
const emoji = seg.slice(idx + 1).trim();
if (userId && emoji) map.set(userId, emoji);
}
return map;
})(),
CLAIMER_EMOJI_FALLBACK: process.env.CLAIMER_EMOJI_FALLBACK || '🎫',
ADMIN_ID: process.env.ADMIN_ID || null,
STAFF_NOTIFICATION_CATEGORY_ID: process.env.STAFF_NOTIFICATION_CATEGORY_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,
USER_PATTERNS_CHANNEL_ID: process.env.USER_PATTERNS_CHANNEL_ID || null,
GAME_PATTERNS_CHANNEL_ID: process.env.GAME_PATTERNS_CHANNEL_ID || null,
TAG_PATTERNS_CHANNEL_ID: process.env.TAG_PATTERNS_CHANNEL_ID || null,
ESCALATION_PATTERNS_CHANNEL_ID: process.env.ESCALATION_PATTERNS_CHANNEL_ID || null,
STAFF_PATTERNS_CHANNEL_ID: process.env.STAFF_PATTERNS_CHANNEL_ID || null,
COMBINED_PATTERNS_CHANNEL_ID: process.env.COMBINED_PATTERNS_CHANNEL_ID || null,
PATTERN_USER_TICKET_THRESHOLD: toInt(process.env.PATTERN_USER_TICKET_THRESHOLD, 3),
PATTERN_GAME_TICKET_THRESHOLD: toInt(process.env.PATTERN_GAME_TICKET_THRESHOLD, 10),
PATTERN_STAFF_STALE_PING_THRESHOLD: toInt(process.env.PATTERN_STAFF_STALE_PING_THRESHOLD, 5),
PATTERN_ESCALATION_THRESHOLD: toInt(process.env.PATTERN_ESCALATION_THRESHOLD, 3),
PATTERN_RAPID_CLOSE_SECONDS: toInt(process.env.PATTERN_RAPID_CLOSE_SECONDS, 120),
PATTERN_UNCLAIMED_HOURS: toInt(process.env.PATTERN_UNCLAIMED_HOURS, 4),
PATTERN_CHECK_INTERVAL_MINUTES: toInt(process.env.PATTERN_CHECK_INTERVAL_MINUTES, 30),
ALL_STAFF_CHANNEL_ID: process.env.ALL_STAFF_CHANNEL_ID || null,
ALL_STAFF_CHAT_ALERT_CHANNEL_ID: process.env.ALL_STAFF_CHAT_ALERT_CHANNEL_ID || null,
SURGE_ROLE_ID: process.env.SURGE_ROLE_ID || null,
SURGE_TICKET_COUNT: toInt(process.env.SURGE_TICKET_COUNT, 10),
SURGE_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_TICKET_WINDOW_MINUTES, 30),
SURGE_GAME_TICKET_COUNT: toInt(process.env.SURGE_GAME_TICKET_COUNT, 5),
SURGE_GAME_TICKET_WINDOW_MINUTES: toInt(process.env.SURGE_GAME_TICKET_WINDOW_MINUTES, 30),
SURGE_STALE_COUNT: toInt(process.env.SURGE_STALE_COUNT, 8),
SURGE_STALE_HOURS: toInt(process.env.SURGE_STALE_HOURS, 2),
SURGE_NEEDS_RESPONSE_COUNT: toInt(process.env.SURGE_NEEDS_RESPONSE_COUNT, 5),
SURGE_NEEDS_RESPONSE_HOURS: toInt(process.env.SURGE_NEEDS_RESPONSE_HOURS, 1),
SURGE_UNCLAIMED_COUNT: toInt(process.env.SURGE_UNCLAIMED_COUNT, 5),
SURGE_UNCLAIMED_MINUTES: toInt(process.env.SURGE_UNCLAIMED_MINUTES, 30),
SURGE_TIER3_UNCLAIMED_MINUTES: toInt(process.env.SURGE_TIER3_UNCLAIMED_MINUTES, 15),
SURGE_COOLDOWN_MINUTES: toInt(process.env.SURGE_COOLDOWN_MINUTES, 60),
CHAT_ALERT_CHANNEL_IDS: (process.env.CHAT_ALERT_CHANNEL_IDS || '').split(',').filter(Boolean),
CHAT_ALERT_MESSAGE_COUNT: toInt(process.env.CHAT_ALERT_MESSAGE_COUNT, 5),
CHAT_ALERT_HOURS_WITHOUT_RESPONSE: toInt(process.env.CHAT_ALERT_HOURS_WITHOUT_RESPONSE, 2),
CHAT_ALERT_COOLDOWN_MINUTES: toInt(process.env.CHAT_ALERT_COOLDOWN_MINUTES, 60),
STAFF_IDS: (process.env.STAFF_IDS || '').split(',').map(s => s.trim()).filter(Boolean),
SURGE_NO_STAFF_COOLDOWN_MINUTES: toInt(process.env.SURGE_NO_STAFF_COOLDOWN_MINUTES, 30),
SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD: toInt(process.env.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD, 3),
STAFF_DND_COUNTS_AS_AVAILABLE: process.env.STAFF_DND_COUNTS_AS_AVAILABLE === 'true',
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',
@@ -273,28 +93,9 @@ const CONFIG = {
SETTINGS_ADMIN_PASSWORD: process.env.SETTINGS_ADMIN_PASSWORD || null,
SETTINGS_DOMAIN: process.env.SETTINGS_DOMAIN || 'tickets.indifferentketchup.com',
INTERNAL_API_PORT: toInt(process.env.INTERNAL_API_PORT, 12753),
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null,
NOTIFICATION_THRESHOLDS: parseNotificationThresholdsJson(process.env.NOTIFICATION_THRESHOLDS_JSON),
UNCLAIMED_REMINDER_THRESHOLDS: (process.env.UNCLAIMED_REMINDER_THRESHOLDS || '1,2,4')
.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n > 0)
INTERNAL_API_SECRET: process.env.INTERNAL_API_SECRET || null
};
/** Ticket category tags for /tag set [emoji] [label] in dropdown; priority emoji always first in channel name, then tag emoji. */
const TICKET_TAGS = [
{ value: 'server-down', emoji: '⬇️', name: 'Server Down' },
{ value: 'stuck-restarting', emoji: '⏳', name: 'Stuck Restarting' },
{ value: 'cant-connect', emoji: '📵', name: "Can't Connect" },
{ value: 'server-lag', emoji: '🐌', name: 'Server Lag' },
{ value: 'billing', emoji: '💳', name: 'Billing' },
{ value: 'refund-request', emoji: '💸', name: 'Refund Request' },
{ value: 'mod-help', emoji: '🔧', name: 'Mod Help' },
{ value: 'backup-restore', emoji: '💾', name: 'Backup Restore' },
{ value: 'world-save', emoji: '🌍', name: 'World / Save' },
{ value: 'server-config', emoji: '⚙️', name: 'Server Config' }
];
const GAME_NAMES = (CONFIG.GAME_LIST || '')
.split(',')
.map(g => g.trim())
@@ -311,44 +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,
parseThresholdString,
TICKET_TAGS,
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) {

View File

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

View File

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

View File

@@ -1,665 +0,0 @@
# Discord API Improvements Implementation
Comprehensive upgrade implementing Discord API best practices and advanced features.
---
## 🎉 Implementation Complete
All 12 improvements have been successfully implemented!
---
## ✅ Completed Features
### 1. Interaction Context Restrictions ✅
**What:** Commands now specify where they can be used (guilds only, DMs, everywhere)
**Implementation:**
```javascript
.setContexts([InteractionContextType.Guild])
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
```
**Applied to:**
- `/escalate` - Guild only
- `/add`, `/remove` - Guild only
- `/transfer`, `/move` - Guild only
- `/force-close` - Guild only
- `/topic` - Guild only
- `/panel` - Guild only
- `/priority` - Guild only
- `/search` - Guild only
- `/stats` - Guild only (admin only)
- `/help` - Works everywhere (guild, DM, group DM)
**Benefits:**
- Users only see commands where they work
- No confusing error messages
- Professional UX
---
### 2. String Length Validation ✅
**What:** Enforces minimum and maximum lengths on text inputs
**Implementation:**
```javascript
.addStringOption(opt =>
opt
.setName('reason')
.setMinLength(10)
.setMaxLength(500)
.setRequired(false)
)
```
**Applied to:**
- `/escalate` reason: 10-500 chars
- `/transfer` reason: 10-500 chars
- `/topic` text: 5-1024 chars
- `/response create` name: 2-50 chars
- `/response create` content: 10-2000 chars
- `/response edit` content: 10-2000 chars
- `/panel` title: 5-100 chars
- `/panel` description: 10-500 chars
- `/search` query: 2-100 chars
**Benefits:**
- Prevents spam
- Ensures meaningful inputs
- Better data quality
---
### 3. Permission Checks ✅
**What:** Staff-only commands require specific permissions
**Implementation:**
```javascript
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels)
```
**Applied to:**
- `/escalate` - Manage Messages
- `/add`, `/remove` - Manage Messages
- `/transfer` - Manage Messages
- `/move` - Manage Channels
- `/force-close` - Manage Channels
- `/panel` - Manage Channels
- `/search` - Manage Messages
- `/stats` - Administrator
- Context menu commands - Manage Messages
**Benefits:**
- Regular users don't see staff commands
- Clear role separation
- Reduced clutter
---
### 4. Command Groups (Subcommands) ✅
**What:** Related commands organized under one parent command
**Before:**
- `/response send` - Send saved response
- `/response create` - Create saved response
- `/response edit` - Edit saved response
- `/response delete` - Delete saved response
- `/response list` - List saved responses
**After:**
- `/response send` - Send saved response
- `/response create` - Create saved response
- `/response edit` - Edit saved response
- `/response delete` - Delete saved response
- `/response list` - List saved responses
**Benefits:**
- 5 commands → 1 command
- Better organization
- Industry standard
- Cleaner command list
---
### 5. Context Menu Commands ✅
**What:** Right-click actions on messages and users
**Implemented:**
#### Create Ticket From Message
- Right-click any message → Apps → "Create Ticket From Message"
- Creates ticket with message content
- Adds link to original message
- Perfect for converting user reports
#### View User Tickets
- Right-click any user → Apps → "View User Tickets"
- Shows all tickets for that user
- Displays status, priority, claimed status
- Quick support history lookup
**Benefits:**
- Quick actions without typing commands
- Better workflow for staff
- More intuitive UX
---
### 6. Priority Selection Buttons ✅
**What:** One-click priority changes with visual buttons
**Implementation:**
Every ticket now has a second row of buttons:
- 🟢 Low (Green button)
- 🟡 Normal (Blue button)
- 🔴 High (Red button)
**Benefits:**
- No typing required
- Visual and fast
- Reduces `/priority` command usage
- Clear priority indicators
---
### 7. Thread-Style Tickets ✅
**What:** Option to create tickets as threads instead of channels
**Configuration:**
```env
USE_THREADS=true
THREAD_PARENT_CHANNEL=<channel_id>
```
**Features:**
- Creates private threads in specified channel
- Auto-archive after 24 hours inactive
- No channel limit concerns
- Cleaner server structure
**When to use:**
- High ticket volume (>50/day)
- Channel organization issues
- Want automatic archiving
**Function:**
```javascript
createTicketChannel(guild, ticketNumber, userId, subject)
```
Automatically handles channels OR threads based on config!
---
### 8. Search Command ✅
**What:** Search for tickets by email, subject, or number
**Usage:**
```
/search query:john@example.com status:open
/search query:password reset status:all
/search query:123 status:closed
```
**Features:**
- Searches email, subject, ticket number
- Filter by status (open/closed/all)
- Loading state while searching
- Shows up to 5 results with details
- Displays priority and claim status
**Benefits:**
- Quick ticket lookup
- No need to scroll through channels
- Staff productivity boost
---
### 9. Stats Command ✅
**What:** View bot analytics and performance metrics
**Usage:**
```
/stats
```
**Shows:**
- ⏱️ Bot uptime
- 💬 Total interactions
- 📈 Commands used count
- 🎫 Open/closed/claimed ticket counts
- 🔥 Most used command
- ❌ Error count (last hour)
- 📉 Error rate percentage
- 📋 Top 5 commands with usage counts
**Benefits:**
- Monitor bot health
- Identify popular features
- Track error rates
- Data-driven decisions
---
### 10. Monitoring & Analytics ✅
**What:** Comprehensive tracking system for all interactions
**Tracks:**
- Command usage (each command counted)
- Button clicks (claim, close, priority, etc.)
- Modal submissions
- Context menu usage
- Error occurrences with details
**Analytics Summary:**
```javascript
getAnalyticsSummary() // Returns detailed stats
```
**Features:**
- In-memory tracking (last 100 errors)
- Per-interaction type counters
- Most used command tracking
- Top commands ranking
- Error rate calculation
**Console Output:**
```
📊 Analytics: commands/response by User#1234
📊 Analytics: buttons/priority-select by User#5678
```
**Benefits:**
- Understand usage patterns
- Identify unused features
- Monitor bot health
- Optimize workflows
---
### 11. Error Rate Tracking ✅
**What:** Automatic error monitoring and alerting
**Features:**
- Tracks all errors with full context
- Stores last 100 errors
- Calculates hourly error rate
- Warns if error rate > 5%
- Includes stack traces
**Error Entry:**
```javascript
{
context: 'tag-create',
message: 'UNIQUE constraint failed',
stack: '...',
timestamp: 1234567890,
user: 'User#1234',
command: 'tag'
}
```
**Console Warnings:**
```
❌ Error tracked: tag-create: UNIQUE constraint failed
⚠️ HIGH ERROR RATE: 6.5% in last hour
```
**Benefits:**
- Early problem detection
- Detailed error logs
- Automatic alerting
- Better debugging
---
### 12. Loading States & Confirmations ✅
**What:** Better UX with loading indicators and confirmations
**Loading States (deferReply):**
- `/search` - Shows "thinking" while searching
- `/stats` - Shows "thinking" while calculating
- `/response list` - Shows "thinking" while fetching
- Context menu commands - Always deferred
- Modal submissions - Always deferred
**Confirmation Prompts:**
- **Tag Delete:** Shows "Yes, Delete Tag" and "Cancel" buttons
- **Ticket Close:** Shows "Confirm Close" and "Cancel" buttons (existing)
**Benefits:**
- User knows bot is working
- Prevents accidental deletions
- Professional feel
- Reduces user anxiety
---
## 📊 Implementation Statistics
| Metric | Count |
|--------|-------|
| **Slash Commands** | 13 (was 15, now 13 due to grouping) |
| **Context Menu Commands** | 2 (new!) |
| **Total Commands** | 15 |
| **Subcommands** | 5 (under `/response`) |
| **New Buttons** | 6 (3 priority + 2 confirm/cancel + tag delete) |
| **New Functions** | 5+ (analytics, tracking, thread creation) |
| **Lines of Code Added** | ~800+ |
| **Config Variables Used** | 2 (USE_THREADS, THREAD_PARENT_CHANNEL) |
---
## 🎯 Command Reference (Updated)
### User Commands
- `/help` - Show help (works everywhere)
### Ticket Management (Staff)
- `/add @user` - Add user to ticket (Guild, Manage Messages)
- `/remove @user` - Remove user (Guild, Manage Messages)
- `/transfer @staff [reason]` - Transfer ticket (Guild, Manage Messages)
- `/move #category` - Move to category (Guild, Manage Channels)
- `/force-close` - Force close (Guild, Manage Channels)
- `/topic <text>` - Set topic (Guild)
- `/priority <level>` - Set priority: low, normal, medium, high. Posts upgraded/downgraded/normal message; email sent when set to **high** (Guild)
- `/escalate [reason] [tier]` - Escalate to tier 2 or 3 (Guild, Manage Messages)
- `/deescalate` - De-escalate one step (Guild, Manage Messages)
### Tag & Response
- `/tag` - Set ticket category (dropdown: ⬇️ Server Down, 💳 Billing, 🔧 Mod Help, etc.). Posts: *Your ticket has been categorized as [Emoji][Tag][Emoji].* (no channel rename)
- `/response send|create|edit|delete|list` - Saved response templates (custom tags)
### System & Admin
- `/panel #channel [title] [description]` - Create panel (Guild, Manage Channels)
- `/search <query> [status]` - Search tickets (Guild, Manage Messages)
- `/stats` - View analytics (Guild, Administrator)
### Context Menu
- Right-click message → "Create Ticket From Message" (Guild, Manage Messages)
- Right-click user → "View User Tickets" (Guild, Manage Messages)
---
## 🔧 Configuration
### Environment Variables
**Existing:**
All previous `.env` variables still work.
**New:**
```env
# Thread-Style Tickets (Optional)
USE_THREADS=false
THREAD_PARENT_CHANNEL=
```
**To enable threads:**
1. Create a text channel for ticket threads
2. Copy its ID
3. Set `USE_THREADS=true`
4. Set `THREAD_PARENT_CHANNEL=<channel_id>`
5. Restart bot
---
## 💡 Usage Examples
### For Staff
**Quick Priority Change:**
1. Click 🟢 Low, 🟡 Normal, or 🔴 High button
2. Done! No typing needed
**Search for a Ticket:**
```
/search query:john@example.com status:open
```
**Create Ticket from User Message:**
1. Right-click message
2. Apps → "Create Ticket From Message"
3. Ticket created instantly!
**View User History:**
1. Right-click user
2. Apps → "View User Tickets"
3. See all their tickets
**Use a Saved Response:**
```
/response send welcome
```
(Autocomplete shows all tags!)
**Check Bot Health:**
```
/stats
```
### For Admins
**View Analytics:**
```
/stats
```
See usage, errors, top commands
**Create Ticket Panel:**
```
/panel #support-tickets
```
**Enable Threads:**
```env
USE_THREADS=true
THREAD_PARENT_CHANNEL=1234567890
```
---
## 🚀 Performance Impact
### Memory
- Analytics: ~1-5 KB (100 errors max)
- No significant increase
### Speed
- Commands respond instantly
- Loading states for operations >3s
- No performance degradation
### Database
- No schema changes required
- All existing data compatible
---
## 🔍 What Changed Internally
### Command Registration
- Added contexts and integration types
- Added permission checks
- Added string validation
- Grouped tag commands
- Added 2 context menu commands
### Interaction Handler
- Updated tag handling for subcommands
- Added search command handler
- Added stats command handler
- Added 2 context menu handlers
- Added analytics tracking
- Added error tracking
- Added loading states
- Priority set via `/priority` command only (no priority buttons in tickets)
- Added tag delete confirmation
### New Functions
- `trackInteraction()` - Track usage
- `trackError()` - Log errors
- `getTotalInteractions()` - Count interactions
- `getAnalyticsSummary()` - Generate stats
- `createTicketChannel()` - Unified channel/thread creation
### Analytics Object
```javascript
{
commands: { 'tag': 42, 'search': 15, ... },
buttons: { ... },
modals: { ... },
contextMenus: { ... },
errors: [...],
startTime: 1234567890
}
```
---
## 🐛 Troubleshooting
### Commands Not Showing
Wait up to 1 hour for Discord to sync globally-scoped commands.
### Context Menu Not Appearing
- Verify permissions set correctly
- Check user has required permission
- Try in different channel
### Threads Not Creating
- Verify `THREAD_PARENT_CHANNEL` is valid channel ID
- Ensure bot has permission to create threads
- Check channel is a text channel
### Stats Showing Zeros
- Stats accumulate over time
- Restart resets counters
- Use some commands to see stats populate
---
## 📈 Migration Guide
### No Breaking Changes!
All existing functionality preserved.
### Steps
1.**Backup your database** (just in case)
2.**Update code** (done!)
3.**Restart bot**
4.**Commands re-register automatically**
5.**Test new features**
### Optional: Enable Threads
```env
USE_THREADS=true
THREAD_PARENT_CHANNEL=<your_channel_id>
```
### New Commands to Try
```
/search query:test
/stats
/response list
Right-click message → Create Ticket
```
---
## 🎓 Best Practices
### Using Search
- Search by email for user lookup
- Search by keywords for subject
- Use status filter to narrow results
### Using Stats
- Check daily for error rates
- Monitor most-used commands
- Identify unused features
### Using Context Menus
- Train staff on right-click actions
- Faster than typing commands
- Great for quick workflows
### Using /priority
- Set priority via `/priority` (dropdown: low, normal, medium, high)
- Channel/thread name is prefixed with the priority emoji
- No priority buttons on tickets; command only
---
## 🏆 Benefits Summary
### User Experience
- ✅ Commands only show where they work
- ✅ Meaningful validation messages
- ✅ Loading indicators
- ✅ Confirmation prompts
- ✅ Priority via `/priority` (channel name shows emoji)
- ✅ Quick actions via context menus
### Staff Productivity
- ✅ Faster ticket search
- ✅ Quick user history lookup
- ✅ Priority via `/priority` command
- ✅ Organized command structure
- ✅ Context menu shortcuts
### Admin Visibility
- ✅ Usage analytics
- ✅ Error monitoring
- ✅ Performance metrics
- ✅ Feature adoption tracking
### Code Quality
- ✅ Better organization
- ✅ Comprehensive tracking
- ✅ Professional error handling
- ✅ Discord API best practices
- ✅ Future-proof architecture
---
## 📚 Documentation Files
1. **DISCORD_API_VALIDATION.md** - Original validation report
2. **DISCORD_API_IMPROVEMENTS.md** - This file
3. **PHASE_FEATURES.md** - Previous features
4. **QUICKSTART.md** - Getting started guide
---
## ✨ What's Next?
All requested features implemented! Optional future enhancements:
1. **Localization** - Multi-language support
2. **Advanced Automation** - Rule builder
3. **Web Dashboard** - Browser interface
4. **More Context Menus** - Additional actions
5. **Custom Analytics Dashboard** - Visual graphs
---
**Implementation Date:** February 2025
**Version:** 3.0.0
**Status:** Complete ✅
**Compliance:** Discord API v10 Best Practices ✅
**All features production-ready and tested!** 🚀

View File

@@ -1,570 +0,0 @@
# Discord API Implementation Validation Report
This document validates our ticket system implementation against the official Discord API documentation.
---
## ✅ Implementation Status
### Overall Assessment: **EXCELLENT**
Our implementation follows Discord.js best practices and official Discord API guidelines. All features are correctly implemented using proper interaction types, component structures, and response patterns.
---
## 📋 Feature-by-Feature Validation
### 1. Slash Commands ✅ VALID
**Implementation:**
```javascript
new SlashCommandBuilder()
.setName('add')
.setDescription('Add a user to this ticket thread')
.addUserOption(opt =>
opt.setName('user').setDescription('User to add').setRequired(true)
)
```
**Discord API Requirements:**
- ✅ Command names match regex `^[-_'\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$`
- ✅ Names are 1-32 characters
- ✅ Descriptions are 1-100 characters
- ✅ Using proper option types (User, String, Channel, etc.)
- ✅ Required options before optional options
- ✅ Max 25 options per command (we use 1-2)
**Compliance: 100%**
---
### 2. Modal Forms ✅ VALID
**Implementation:**
```javascript
const modal = new ModalBuilder()
.setCustomId('ticket_modal')
.setTitle('Create Support Ticket');
const subjectInput = new TextInputBuilder()
.setCustomId('ticket_subject')
.setLabel('Subject')
.setStyle(TextInputStyle.Short)
.setRequired(true)
.setMaxLength(100);
```
**Discord API Requirements:**
- ✅ Modal opened in response to interaction (button click)
- ✅ Custom IDs are unique and 1-100 characters
- ✅ Using ActionRowBuilder for layout
- ✅ TextInputBuilder with proper style (Short/Paragraph)
- ✅ Max length constraints set appropriately
- ✅ Handling MODAL_SUBMIT interaction type (5)
**Best Practices:**
- ✅ Using descriptive custom IDs
- ✅ Appropriate input styles (Short for subject, Paragraph for description)
- ✅ Validation on submission
- ✅ User feedback with `deferReply` and `editReply`
**Compliance: 100%**
---
### 3. Message Components (Buttons) ✅ VALID
**Implementation:**
```javascript
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('close_ticket')
.setLabel(CONFIG.BUTTON_LABEL_CLOSE)
.setEmoji(CONFIG.BUTTON_EMOJI_CLOSE)
.setStyle(ButtonStyle.Danger),
new ButtonBuilder()
.setCustomId('claim_ticket')
.setLabel(CONFIG.BUTTON_LABEL_CLAIM)
.setStyle(ButtonStyle.Primary)
);
```
**Discord API Requirements:**
- ✅ Buttons in ActionRowBuilder (type 1)
- ✅ Max 5 buttons per ActionRow (we use 2)
- ✅ Custom IDs are unique
- ✅ Using valid ButtonStyles (Danger, Primary, Secondary)
- ✅ Labels set appropriately
- ✅ Emoji support included
**Compliance: 100%**
---
### 4. Button Interactions ✅ VALID
**Implementation:**
```javascript
if (interaction.isButton()) {
if (interaction.customId === 'open_ticket') {
return await interaction.showModal(modal);
}
}
```
**Discord API Requirements:**
- ✅ Checking interaction type correctly
- ✅ Reading custom_id from interaction
- ✅ Responding appropriately (showModal, reply, update)
- ✅ Using ephemeral responses where appropriate
**Compliance: 100%**
---
### 5. Autocomplete ✅ VALID
**Implementation:**
```javascript
if (interaction.isAutocomplete()) {
if (interaction.commandName === 'tag' && ['edit', 'delete'].includes(interaction.options.getSubcommand(false))) {
const focusedValue = interaction.options.getFocused();
const tags = await dbAll('SELECT name FROM tags ORDER BY name');
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);
}
}
```
**Discord API Requirements:**
- ✅ Handling APPLICATION_COMMAND_AUTOCOMPLETE type (4)
- ✅ Max 25 choices returned
- ✅ Choices have name and value fields
- ✅ Filtering based on focused value
- ✅ Responding with `interaction.respond()`
**Compliance: 100%**
---
### 6. Interaction Response Types ✅ VALID
**Our Usage:**
-`interaction.reply()` - Initial response
-`interaction.update()` - Update message components
-`interaction.followUp()` - Additional messages
-`interaction.deferReply()` - Acknowledge with thinking state
-`interaction.editReply()` - Edit deferred response
-`interaction.showModal()` - Display modal form
**Discord API Callback Types:**
- Type 1: PONG (not needed for Gateway)
- Type 4: CHANNEL_MESSAGE_WITH_SOURCE (our `reply()`)
- Type 5: DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE (our `deferReply()`)
- Type 6: DEFERRED_UPDATE_MESSAGE (for components)
- Type 7: UPDATE_MESSAGE (our `update()`)
- Type 9: MODAL (our `showModal()`)
**Compliance: 100%**
---
## 🔍 Advanced Features Review
### Permission Handling ✅ EXCELLENT
**Implementation:**
```javascript
await interaction.channel.permissionOverwrites.create(user.id, {
ViewChannel: true,
SendMessages: true,
ReadMessageHistory: true
});
```
**Discord API:**
- ✅ Using proper PermissionFlagsBits
- ✅ Correct permission names
- ✅ Async/await pattern
- ✅ Error handling
---
### Channel Operations ✅ EXCELLENT
**Implementation:**
```javascript
const channel = await guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: category.id,
permissionOverwrites: [...]
});
```
**Discord API:**
- ✅ Using ChannelType enum
- ✅ Setting parent category
- ✅ Permission overwrites on creation
- ✅ Proper channel naming
---
### Embed Usage ✅ EXCELLENT
**Implementation:**
```javascript
const embed = new EmbedBuilder()
.setTitle(`Ticket #${ticketNumber}: ${subject}`)
.setDescription(description)
.addFields([...])
.setColor(getPriorityColor(priority))
.setTimestamp();
```
**Discord API:**
- ✅ Using EmbedBuilder
- ✅ Title limit (256 chars) respected
- ✅ Description limit (4096 chars) respected
- ✅ Field limits (25 max) respected
- ✅ Color as integer (hex format)
---
## 🚨 Potential Issues & Recommendations
### ⚠️ Minor: Rate Limit Considerations
**Current Implementation:**
Our code creates channels and renames them without explicit rate limit handling.
**Discord Rate Limits:**
- Channel creation: 50/day per guild
- Channel rename: 2 per 10 minutes per channel **per bot token**
**Our Protection:**
- ✅ 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()` is retained as an always-ok shim for back-compat.
**Recommendation:** Current implementation is GOOD. No changes needed.
---
### ⚠️ Minor: Interaction Token Expiration
**Discord Requirement:**
Interaction tokens expire after 15 minutes.
**Our Implementation:**
- ✅ We respond to interactions immediately
- ✅ We use `deferReply()` for long operations
- ✅ All operations complete within 15 minutes
**Status:** COMPLIANT
---
### ✅ Good: Ephemeral Messages
**Implementation:**
```javascript
await interaction.reply({
content: 'Error message',
ephemeral: true
});
```
**Usage:**
- ✅ Error messages are ephemeral
- ✅ Confirmation prompts are ephemeral
- ✅ Help command is ephemeral
- ✅ Tag list is ephemeral
**Status:** EXCELLENT - Following best practices
---
## 📊 Component Limits Compliance
| Component Type | Discord Limit | Our Usage | Status |
|---------------|---------------|-----------|---------|
| Slash Commands | Global unlimited | 15 | ✅ |
| Command Options | 25 per command | 1-2 | ✅ |
| Buttons per Row | 5 | 2 | ✅ |
| Action Rows per Message | 5 | 1-2 | ✅ |
| Modal Components | 5 | 3 | ✅ |
| Autocomplete Choices | 25 | Capped at 25 | ✅ |
| Embed Fields | 25 | 3-5 | ✅ |
| Select Menu Options | 25 | N/A | ✅ |
**All limits respected: 100% compliance**
---
## 🎯 Best Practices Validation
### ✅ We Follow All Discord Best Practices:
1. **Error Handling**
- ✅ Try-catch blocks around all interactions
- ✅ User-friendly error messages
- ✅ Logging errors to console
- ✅ Graceful degradation
2. **User Experience**
- ✅ Ephemeral for private messages
- ✅ Clear button labels
- ✅ Emoji indicators
- ✅ Confirmation prompts
- ✅ Loading states (deferReply)
3. **Security**
- ✅ Permission checks before operations
- ✅ Role validation
- ✅ Input validation
- ✅ Parameterized queries
4. **Performance**
- ✅ Efficient database queries
- ✅ Proper async/await usage
- ✅ Caching where appropriate
- ✅ Rate limit awareness
5. **Maintainability**
- ✅ Modular code structure
- ✅ Clear variable names
- ✅ Comments where needed
- ✅ Configuration via environment variables
---
## 🆕 New Discord Features to Consider
### Components V2 (Optional)
**What is it:**
New component system with:
- Text Display components
- Media Gallery
- Containers and Sections
- File Upload in modals
**Should we use it?**
- ⚠️ Requires flag `1 << 15` (IS_COMPONENTS_V2)
- ⚠️ Disables traditional `content` and `embeds`
- ⚠️ More complex implementation
- ✅ Our current implementation is stable
**Recommendation:** WAIT. Components V2 is optional and our current implementation works perfectly. Monitor Discord.js support before migrating.
---
### Context Menu Commands
**Not Currently Used:**
- User commands (right-click user)
- Message commands (right-click message)
**Potential Use Cases:**
- `/ticket-from-message` - Create ticket from a message
- `/user-tickets` - View user's tickets (right-click user)
**Priority:** LOW - Current slash commands are sufficient
---
### Thread-Style Tickets
**Status:** Configuration ready (`USE_THREADS=true`)
**Implementation Needed:**
```javascript
// Instead of channels.create():
const thread = await channel.threads.create({
name: `ticket-${ticketNumber}`,
autoArchiveDuration: 60,
type: ChannelType.PrivateThread, // or PublicThread
reason: 'Ticket creation'
});
```
**Benefits:**
- Cleaner server structure
- No channel limit concerns
- Auto-archive capability
- Better for high-volume
**Recommendation:** Implement when needed. Foundation is ready.
---
## 🔬 Code Quality Assessment
### Discord.js Version Compatibility ✅
**Current:** discord.js v14.x (based on imports)
**Features Used:**
- ✅ SlashCommandBuilder
- ✅ ModalBuilder
- ✅ TextInputBuilder
- ✅ ActionRowBuilder
- ✅ ButtonBuilder
- ✅ EmbedBuilder
- ✅ PermissionFlagsBits
- ✅ ChannelType enum
- ✅ TextInputStyle enum
**All features are stable in v14. No deprecation warnings.**
---
### Type Safety ✅ GOOD
**Interaction Type Checking:**
```javascript
if (interaction.isButton()) { ... }
if (interaction.isModalSubmit()) { ... }
if (interaction.isChatInputCommand()) { ... }
if (interaction.isAutocomplete()) { ... }
```
**Status:** Excellent - Using proper type guards
---
### Event Handling ✅ EXCELLENT
**Implementation:**
```javascript
client.on('interactionCreate', async interaction => {
// Handle buttons
if (interaction.isButton()) { ... }
// Handle modals
if (interaction.isModalSubmit()) { ... }
// Handle commands
if (interaction.isChatInputCommand()) { ... }
// Handle autocomplete
if (interaction.isAutocomplete()) { ... }
});
```
**Status:** Perfect structure - All interaction types handled appropriately
---
## 📝 Recommendations Summary
### Must Do (Critical) ✅
**NOTHING** - All critical requirements met
### Should Do (Important)
1.**DONE** - All important features implemented
### Could Do (Nice to Have)
1. **Add Interaction Logging** (Optional)
```javascript
console.log(`Interaction: ${interaction.commandName} by ${interaction.user.tag}`);
```
2. **Add Metrics Collection** (Optional)
- Track command usage
- Track modal submissions
- Track button clicks
3. **Implement Context Menu Commands** (Low Priority)
- User commands for quick actions
- Message commands for ticket creation
### Won't Do (Not Recommended)
1. **Components V2** - Too early, wait for ecosystem maturity
2. **HTTP Interactions Endpoint** - Gateway works perfectly for bots
---
## 🎓 Discord API Knowledge Validation
### Core Concepts ✅ Mastered
1. **Interaction Types**
- ✅ PING (1) - Not applicable for Gateway bots
- ✅ APPLICATION_COMMAND (2) - Slash commands
- ✅ MESSAGE_COMPONENT (3) - Buttons, selects
- ✅ APPLICATION_COMMAND_AUTOCOMPLETE (4) - Tag autocomplete
- ✅ MODAL_SUBMIT (5) - Form submissions
2. **Component Types**
- ✅ Action Row (1) - Layout container
- ✅ Button (2) - Interactive buttons
- ✅ Text Input (4) - Modal form fields
3. **Response Types**
- ✅ PONG - N/A for Gateway
- ✅ CHANNEL_MESSAGE_WITH_SOURCE (4) - reply()
- ✅ DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE (5) - deferReply()
- ✅ UPDATE_MESSAGE (7) - update()
- ✅ MODAL (9) - showModal()
**Understanding: COMPREHENSIVE**
---
## 🏆 Final Score
| Category | Score | Status |
|----------|-------|---------|
| API Compliance | 100% | ✅ Perfect |
| Best Practices | 100% | ✅ Excellent |
| Security | 100% | ✅ Secure |
| User Experience | 100% | ✅ Excellent |
| Performance | 100% | ✅ Optimized |
| Maintainability | 100% | ✅ Clean Code |
| Documentation | 100% | ✅ Comprehensive |
**Overall Grade: A+ (100%)**
---
## ✅ Certification Statement
**This implementation is:**
- ✅ Fully compliant with Discord API specifications
- ✅ Following all Discord.js v14 best practices
- ✅ Production-ready and battle-tested
- ✅ Secure and performant
- ✅ Well-documented and maintainable
**Validator:** Discord API Documentation v10
**Date:** February 2025
**Status:** APPROVED FOR PRODUCTION ✅
---
## 📚 References
**Official Documentation:**
- [Discord API Docs - Interactions Overview](https://discord.com/developers/docs/interactions/overview)
- [Discord API Docs - Application Commands](https://discord.com/developers/docs/interactions/application-commands)
- [Discord API Docs - Message Components](https://discord.com/developers/docs/interactions/message-components)
- [Discord API Docs - Receiving and Responding](https://discord.com/developers/docs/interactions/receiving-and-responding)
- [Discord.js Guide](https://discordjs.guide/)
**Our Implementation Files:**
- `broccolini-discord.js` - Main bot implementation
- `PHASE_FEATURES.md` - Feature documentation
- `QUICKSTART.md` - Quick start guide
---
**Validated By:** Discord API Compliance Review
**Validation Date:** 2025-02-10
**Next Review:** When Discord.js v15 releases or significant API changes occur
**Status: PRODUCTION READY**

View File

@@ -1,83 +0,0 @@
# Broccolini Bot Commands Analysis
Analysis of slash commands and context menus: who can see/use them today, and how to restrict usage to the support role (@broccolini / `ROLE_ID_TO_PING`) only so customers cannot use them.
---
## Current permission model
Commands use **Discord permission bits** via `setDefaultMemberPermissions(...)`. Only users who have that permission (or Administrator) see the command in the slash menu. There is **no role-ID check** at registration time (Discord API does not support “visible only to role X”).
| Command | Registration permission | Handler: staff only? | Who can use today |
|--------|-------------------------|----------------------|--------------------|
| **Slash commands** | | | |
| `/escalate` | ManageMessages | Yes | Broccolini (staff role) only |
| `/deescalate` | ManageMessages | Yes | Broccolini only |
| `/add` | ManageMessages | Yes | Broccolini only |
| `/remove` | ManageMessages | Yes | Broccolini only |
| `/transfer` | ManageMessages | Yes (caller + target staff) | Broccolini only |
| `/move` | ManageChannels | Yes | Broccolini only |
| `/force-close` | ManageChannels | Yes | Broccolini only |
| `/topic` | ManageMessages | Yes | Broccolini only |
| `/tag` | ManageMessages | Yes | Broccolini only |
| `/response` | ManageMessages | Yes | Broccolini only |
| `/help` | **None** | **No** | **Everyone** |
| `/setup` | ManageChannels | Yes | Broccolini only |
| `/panel` | ManageChannels | Yes | Broccolini only |
| `/email-routing` | ManageGuild | Yes | Broccolini only |
| `/backup` | Administrator | Yes | Broccolini only |
| `/export` | Administrator | Yes | Broccolini only |
| `/priority` | ManageMessages | Yes | Broccolini only |
| `/search` | ManageMessages | Yes | Broccolini only |
| `/stats` | Administrator | Yes | Broccolini only |
| `/accountinfo` | ManageMessages | Yes | Broccolini only |
| **Context menus** | | | |
| Create Ticket From Message | ManageMessages | Yes | Broccolini only |
| View User Tickets | ManageMessages | Yes | Broccolini only |
---
## Role used for pinging
- **`ROLE_ID_TO_PING`** (env: `ROLE_ID_TO_PING`) The “@broccolini” role ID used to ping support on new tickets, escalations, etc. Same as `ROLE_TO_PING_ID` in config (alias).
- **`ADDITIONAL_STAFF_ROLES`** Optional comma-separated role IDs; members with any of these roles can also use staff-only commands (same as having `ROLE_ID_TO_PING`). `/transfer` also validates that the *target* user has the main staff role.
---
## Goal: “Support @broccolini @role to ping id only I dont want customers using them”
You want **all** bot commands to be usable **only** by users who have the support role (the one you ping, i.e. `ROLE_ID_TO_PING`), so customers cannot use them.
- **Ping role** = same role as today: `ROLE_ID_TO_PING` (e.g. @broccolini). No change to who gets pinged.
- **Who can run commands** = only members who have that role (and optionally `ADDITIONAL_STAFF_ROLES`). No permission-bit-only access.
Discord does **not** let you restrict slash commands by role ID in the registration. So the way to get “only this role can use” is:
1. **In the handler**: For every guild interaction, before running any command logic, check that the member has `ROLE_ID_TO_PING` (or one of `ADDITIONAL_STAFF_ROLES`). If not, reply ephemeral e.g. “This command is only available to the support team.” and do not run the command.
2. **Registration**: Leave as-is (or tighten for consistency). The role check in the handler is the real gate; permission bits only control who *sees* the command. If you want only staff to see commands, youd give the support role a permission (e.g. Manage Messages) and set that on commands; the handler check still ensures only that role (and optional additional staff roles) can actually run them.
---
## Who can use what (current behavior)
- **Only `/help`** Usable by everyone (no staff role required). Visible in guild and in DMs; in DMs there is no role check.
- **All other commands** (including `/topic` and `/priority`) Broccolini-only. The handler requires the support role (`ROLE_ID_TO_PING` or `ADDITIONAL_STAFF_ROLES`) in guild; customers get an ephemeral “This command is only available to the support team.” and the command does not run.
Note: `/topic` and `/priority` use `ManageMessages` in registration, so they appear in the slash menu only for users with that permission (typically staff). The handler also enforces the staff role. Only `/help` has no default permission and is visible to everyone.
---
## Implementation (done)
- **`handlers/commands.js`**
- **`hasStaffRole(member)`** Returns true if the member has `ROLE_ID_TO_PING` or any `ADDITIONAL_STAFF_ROLES`.
- **`requireStaffRole(interaction)`** If the interaction is in a guild and the user is not staff, replies ephemeral with “This command is only available to the support team (@role).” and returns `true` (so the handler returns without running the command). If not in a guild (e.g. `/help` in DMs), or if no staff roles are configured, no block is applied.
- **`handleCommand`** Calls `requireStaffRole(interaction)` at the top **except for `/help`**; if it returns true, the handler returns immediately.
- **`handleContextMenu`** Same check at the top for “Create Ticket From Message” and “View User Tickets”.
- **Behavior**
- **`/help`** Can be used by everyone (no staff role required).
- **All other slash commands** In a guild, only users with the staff role (ROLE_ID_TO_PING or ADDITIONAL_STAFF_ROLES) can use them; others get an ephemeral message and the command does not run.
- **Context menus** Staff role required in guild.
- In **DM** (e.g. `/help` in BotDM): No role check, so help and any other DM commands still work.
- If **`ROLE_ID_TO_PING`** and **`ADDITIONAL_STAFF_ROLES`** are both unset, the check is skipped (backward compatible).

View File

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

View File

@@ -1,331 +0,0 @@
# 🎉 New Features Summary
All requested features have been added to Broccolini Bot!
## ✅ What's New
### 1. **Auto-Close Automation** ✅
Automatically closes tickets after period of inactivity
- Configurable timeout (default: 72 hours)
- Sends notification message
- Sends close email to customer
- Runs every hour
### 2. **Ticket Limits** ✅
Prevents spam and abuse with configurable limits
- Global limit per user (default: 5 open tickets)
- Per-category limits (ready to implement)
- Gracefully handles limit violations
### 3. **Permission Controls** ✅
Enhanced access control
- Blacklisted roles (cannot create tickets)
- Additional staff roles (framework ready)
- Helper functions for permission checking
### 4. **Welcome Messages** ✅
Professional greeting when tickets are created
- Sent when new tickets are created
- Not sent on ticket reopens
- Fully customizable via .env
### 5. **Reminder Messages** ✅
Keeps tickets active with automated reminders
- Configurable reminder interval (default: 24 hours)
- Sent once per inactivity period
- Resets when ticket gets new activity
### 6. **Priority Levels** ✅ (Backend Ready)
Categorize tickets by urgency
- Three levels: high, normal, low
- Custom emojis per level
- Database and helpers ready
- *UI needs: slash command to set priority*
### 7. **Button & Embed Customization** ✅
Full visual control
- Customizable button labels
- Customizable button emojis
- Configurable embed colors per state
- Easy rebranding
### 8. **Activity Tracking** ✅
Smart monitoring of ticket engagement
- Tracks last message time
- Powers auto-close feature
- Powers reminder feature
- Updates on every interaction
## 🗂️ Files Modified
### Configuration
-`.env` (repo root) - Added 40+ new environment variables
-`package.json` - Scripts: `npm start`, `npm run test-mongodb`
### Code
-`broccolini-discord.js` - All features integrated
-`models.js` - MongoDB schemas updated with new fields
### Database
- ✅ MongoDB schemas (Mongoose) for tickets, tags, close requests, etc.
### Documentation
-`NEW_FEATURES.md` - Detailed feature documentation
-`FEATURES_SUMMARY.md` - This file!
-`MONGODB_SETUP.md` - MongoDB integration guide (already existed)
## 🚀 Quick Start
### 1. Configure Features
All features are pre-configured in `.env` with sensible defaults. Adjust as needed:
**Essential Settings:**
```env
AUTO_CLOSE_ENABLED=true
AUTO_CLOSE_AFTER_HOURS=72
REMINDER_ENABLED=true
REMINDER_AFTER_HOURS=24
GLOBAL_TICKET_LIMIT=5
```
**Customization:**
```env
TICKET_WELCOME_MESSAGE=Your custom welcome message here
BUTTON_LABEL_CLOSE=Your custom label
EMBED_COLOR_OPEN=0x00FF00
```
### 2. Start the Bot
```bash
npm start
# or
node broccolini-discord.js
```
### 3. Verify Features
Watch the console on startup:
```
✓ Auto-close enabled: checking every hour
✓ Reminders enabled: checking every 30 minutes
✓ Discord bot ready. Tag: YourBot#1234
```
## 📋 What Works Right Now
| Feature | Status | Can Use Immediately? |
|---------|--------|---------------------|
| Auto-Close | ✅ Working | Yes |
| Ticket Limits | ✅ Working | Yes |
| Blacklisted Roles | ✅ Working | Yes (add role IDs to .env) |
| Welcome Messages | ✅ Working | Yes |
| Reminder Messages | ✅ Working | Yes |
| Button Customization | ✅ Working | Yes |
| Embed Colors | ✅ Working | Yes |
| Activity Tracking | ✅ Working | Yes (automatic) |
| Priority Levels | ✅ Working | Use `/priority` slash command |
| Modal Forms | ✅ Working | Panel "Open Ticket" → modal form |
## 🎯 Testing Your New Features
### Test Auto-Close:
1. Create a ticket
2. Don't send any messages
3. Wait (or manually set `last_activity` in DB to past)
4. Watch it auto-close after configured time
### Test Reminders:
1. Create a ticket
2. Don't send messages
3. After REMINDER_AFTER_HOURS, see reminder message
4. Send a message
5. Reminder flag resets (can remind again)
### Test Ticket Limits:
1. Set `GLOBAL_TICKET_LIMIT=2` in .env
2. Create 2 tickets from same email
3. Try to create 3rd ticket
4. Verify it's rejected (check logs)
### Test Welcome Messages:
1. Create new ticket
2. See welcome message
3. Reply to ticket email (reopens)
4. Verify welcome message doesn't appear again
### Test Customization:
1. Change button labels/colors in .env
2. Restart bot
3. Create ticket
4. See new labels/colors
## 🔧 Configuration Reference
### Auto-Close Settings
```env
AUTO_CLOSE_ENABLED=true # Enable/disable feature
AUTO_CLOSE_AFTER_HOURS=72 # Hours of inactivity before close
AUTO_CLOSE_MESSAGE=Custom message # Message sent when auto-closing
```
### Ticket Limits
```env
GLOBAL_TICKET_LIMIT=5 # Max open tickets per user
TICKET_LIMIT_PER_CATEGORY=3 # Per-category limit (future)
```
### Permissions
```env
BLACKLISTED_ROLES=role_id1,role_id2 # Comma-separated role IDs
ADDITIONAL_STAFF_ROLES=role_id3 # Extra staff roles
```
### Messages
```env
TICKET_WELCOME_MESSAGE=Welcome!
TICKET_CLAIMED_MESSAGE=Claimed by {staff_name}
TICKET_UNCLAIMED_MESSAGE=Ticket released
REMINDER_MESSAGE=Inactive for {hours} hours
```
### Reminders
```env
REMINDER_ENABLED=true # Enable/disable feature
REMINDER_AFTER_HOURS=24 # Hours before reminder
```
### Priority
```env
PRIORITY_ENABLED=true # Enable/disable feature
DEFAULT_PRIORITY=normal # Default: low/normal/high
PRIORITY_HIGH_EMOJI=🔴
PRIORITY_MEDIUM_EMOJI=🟡
PRIORITY_LOW_EMOJI=🟢
```
### Buttons
```env
BUTTON_LABEL_CLOSE=Close Ticket
BUTTON_LABEL_CLAIM=Claim
BUTTON_LABEL_UNCLAIM=Unclaim
BUTTON_EMOJI_CLOSE=🔒
BUTTON_EMOJI_CLAIM=📌
BUTTON_EMOJI_UNCLAIM=🔓
```
### Colors (Hex format)
```env
EMBED_COLOR_OPEN=0x00FF00 # Green
EMBED_COLOR_CLOSED=0xFF0000 # Red
EMBED_COLOR_CLAIMED=0xFFFF00 # Yellow
EMBED_COLOR_ESCALATED=0xFF6600 # Orange
EMBED_COLOR_INFO=0x1e2124 # Dark gray (embeds next to ticket buttons)
```
## 🎨 Customization Examples
### Gaming Theme:
```env
TICKET_WELCOME_MESSAGE=🎮 Welcome to gaming support! Our experts are ready to help.
BUTTON_EMOJI_CLOSE=🛑
BUTTON_EMOJI_CLAIM=🎯
EMBED_COLOR_OPEN=0x7289DA # Discord blue
```
### Professional Theme:
```env
TICKET_WELCOME_MESSAGE=Thank you for contacting support. A representative will assist you shortly.
BUTTON_LABEL_CLOSE=Mark Resolved
BUTTON_LABEL_CLAIM=Take Ownership
EMBED_COLOR_OPEN=0x2C2F33 # Professional dark
```
### Aggressive Auto-Management:
```env
AUTO_CLOSE_AFTER_HOURS=24 # Close after 1 day
REMINDER_AFTER_HOURS=6 # Remind after 6 hours
GLOBAL_TICKET_LIMIT=3 # Strict limit
```
## 💡 Pro Tips
1. **Start Conservative:** Use default settings first, then adjust based on your ticket volume
2. **Monitor Logs:** Watch for "Auto-close enabled" and "Reminders enabled" on startup
3. **Test in Staging:** Test auto-close with a low hour value first (e.g., 1 hour)
4. **Backup data:** Back up MongoDB if migrating or changing schema
5. **Customize Gradually:** Change one setting at a time to see the impact
6. **Use Placeholders:** `{staff_name}` in claim message, `{hours}` in reminder message
## 🐛 Troubleshooting
**Auto-close not working?**
- Check `AUTO_CLOSE_ENABLED=true` in .env
- Verify console shows "Auto-close enabled" on startup
- Check `last_activity` in database is being set
**Reminders not sent?**
- Check `REMINDER_ENABLED=true` in .env
- Verify console shows "Reminders enabled" on startup
- Ensure `last_activity` is older than REMINDER_AFTER_HOURS
**Ticket limit not enforced?**
- Check `GLOBAL_TICKET_LIMIT` is set and > 0
- Verify function `checkTicketLimits()` is being called
- Check logs for "Ticket limit reached" messages
**Colors not changing?**
- Use hex format: `0x00FF00` (not `#00FF00`)
- Restart bot after changing .env
- Check for typos in variable names
**Buttons not customized?**
- Restart bot after .env changes
- Check emoji format (unicode or custom emoji ID)
- Verify button variables start with `BUTTON_`
## 📚 Next Steps
### Immediate (Ready to Use):
1. ✅ Adjust settings in `.env` (repo root) to your preferences
2. ✅ Restart bot with `npm start`
3. ✅ Test each feature
4. ✅ Monitor for a few days
### Short Term (Optional):
5. Display priority emoji in ticket embeds (already set via `/priority`)
6. Add filter by priority in ticket queries
### Medium Term (Future Enhancement):
7. Add email notifications when ticket limits reached
8. Enforce blacklisted roles in all interactions
9. Add statistics dashboard for auto-close/reminder metrics
### Long Term (From Original Plan):
12. 🧪 Add unit tests for new features
13. 🐳 Docker integration
14. 📈 Production monitoring and alerts
## 📞 Support
For questions or issues with the new features, check:
- `NEW_FEATURES.md` - Detailed documentation
- `models.js` - MongoDB (Mongoose) schemas
- Console logs - Watch for error messages
- GitHub Issues - Report bugs or request features
## 🎊 Congratulations!
Your ticket system now has enterprise-grade features:
- ✅ 8 major features fully implemented
- ✅ 40+ configuration options
- ✅ Professional automation
- ✅ Enhanced user experience
- ✅ Production-ready code
**Enjoy your enhanced support system!** 🚀

View File

@@ -1,454 +0,0 @@
# Implementation Summary - Feature Rollout
## Overview
Successfully implemented **50+ new features** across 5 phases, transforming the ticket system into a comprehensive support platform. The project is a **single-level repo** (run from repo root) and uses **MongoDB only** (Mongoose); the schema notes below describe the logical structure implemented in `models.js`.
---
## 📊 Implementation Statistics
- **New Commands**: 15 slash commands
- **New Database Tables**: 2 (tags, close_requests)
- **New Database Columns**: 3 (priority, last_activity, reminder_sent)
- **New Config Variables**: 10+
- **Lines of Code Added**: ~2000+
- **Documentation Pages**: 3 (PHASE_FEATURES.md, QUICKSTART.md, this file)
---
## ✅ Completed Features by Phase
### Phase 1: Foundation (High Priority) ✅
- [x] **Variables System** - Template engine for dynamic messages
- [x] **Tags/Saved Responses** - Complete CRUD operations
- [x] **/add and /remove** - User management in tickets
- [x] **/help Command** - Interactive help system
### Phase 2: Ticket Management (Medium Priority) ✅
- [x] **/transfer** - Transfer tickets between staff with role validation
- [x] **/move** - Move tickets between categories
- [x] **/force-close** - Immediate ticket closure
- [x] **Close Confirmation** - Prevent accidental closes
- [x] **/topic** - Set channel descriptions
### Phase 3: UX Enhancements ✅
- [x] **Modal Forms** - Interactive ticket creation
- [x] **Dropdown/Select Menus** - Priority selection (foundation)
- [x] **Enhanced Claiming** - Overwrite, auto-unclaim, timeout
- [x] **Priority System** - Low/Normal/High with colors
### Phase 4: Category & Panel System ✅
- [x] **Panel System** - User-facing ticket creation
- [x] **Category System** - Multi-category support via /move
- [x] **Discord-Side Tickets** - Tickets without email integration
- [x] **Thread-Style Tickets** - Configuration ready
### Phase 5: Automation (Low Priority) ✅
- [x] **Automation Framework** - Foundation for future rules
- [x] **Auto-Unclaim** - Background job system
- [x] **Variables Integration** - Dynamic automation support
---
## 🗄️ Database Changes
The project uses **MongoDB (Mongoose)**. The following describes the logical schema; see `models.js` for the actual Mongoose schemas.
### New collections / models
- **Tag** Saved responses (name, content, creator, use count).
- **CloseRequest** Tracks pending close confirmations (ticket ID, requested by, reason).
### Modified Ticket model
- Added fields: `priority`, `last_activity`, `reminder_sent` (and related ticket lifecycle fields).
---
## 🎯 Command Reference
### User Management (2 commands)
- `/add @user` - Add user to ticket
- `/remove @user` - Remove user from ticket
### Ticket Management (6 commands)
- `/transfer @staff [reason]` - Transfer ownership
- `/move #category` - Change category
- `/force-close` - Immediate close
- `/topic <text>` - Set description
- `/priority <level>` - Set priority; posts upgraded/downgraded/normal message; email when set to high
- `/escalate [reason] [tier]` - Escalate to tier 2 or 3 (optional tier)
- `/deescalate` - De-escalate one step
### Tags & Saved Responses
- `/tag` - Set ticket category (dropdown); posts categorization message (no channel rename)
- `/response send|create|edit|delete|list` - Saved response templates
### Panel System (1 command)
- `/panel #channel [title] [description]` - Create ticket panel
### Help (1 command)
- `/help` - Show all commands
**Total: 15 commands + button/modal interactions**
---
## 🔧 Configuration Options
### New .env Variables
```env
# Claiming Options
CLAIM_TIMEOUT_ENABLED=false
CLAIM_TIMEOUT_HOURS=48
AUTO_UNCLAIM_ENABLED=false
AUTO_UNCLAIM_AFTER_HOURS=24
ALLOW_CLAIM_OVERWRITE=false
# Thread-Style Tickets
USE_THREADS=false
THREAD_PARENT_CHANNEL=
# Already configured (from previous update):
# - AUTO_CLOSE_ENABLED
# - AUTO_CLOSE_AFTER_HOURS
# - REMINDER_ENABLED
# - REMINDER_AFTER_HOURS
# - PRIORITY_ENABLED
# - Button customization
# - Embed colors
```
---
## 🎨 User Interface Improvements
### Modal Forms
- Subject field (short text, required)
- Description field (paragraph, required)
- Priority field (optional)
### Close Confirmation
- Confirm button (red, danger style)
- Cancel button (gray, secondary style)
- Ephemeral messages (only user sees)
### Priority Indicators
- 🔴 High Priority (red embeds)
- 🟡 Normal Priority (yellow embeds)
- 🟢 Low Priority (green embeds)
### Autocomplete Support
- Saved response names in `/response send` (autocomplete)
- Response names in `/response edit` command
- Response names in `/response delete` command
---
## 🔄 Background Jobs
### Auto-Close (Every Hour)
- Checks tickets older than configured hours
- Closes automatically with message
- Generates transcripts
- Updates the external ticket API (if configured)
### Auto-Unclaim (Every Hour)
- Checks claimed tickets inactive beyond threshold
- Unclaims automatically
- Notifies in channel
- Resets claimed_by
### Reminders (Every 30 Minutes)
- Checks for inactive tickets
- Sends reminder message
- Marks as reminded
- Prevents duplicate reminders
---
## 📋 Variables System
### 20 Available Variables
```javascript
{ticket.user} // Ticket creator username
{ticket.creator} // Alias for ticket.user
{ticket.email} // Customer email
{ticket.number} // Ticket number
{ticket.subject} // Ticket subject
{ticket.claimed} // Yes or No
{ticket.claimedby} // Staff name or Unclaimed
{ticket.priority} // low, normal, or high
{ticket.id} // Internal ID
{staff.user} // Staff username
{staff.name} // Staff display name
{staff.mention} // @mention format
{server.name} // Discord server name
{server.membercount}// Member count
{hours} // Hour value (for messages)
{date} // Current date
{time} // Current time
```
---
## 🚀 Performance Optimizations
### Database Queries
- Indexed on gmail_thread_id (PRIMARY KEY)
- Indexed on discord_thread_id
- Efficient tag lookups by name (UNIQUE)
- Optimized background job queries
### Rate Limit Handling
- Channel rename: Discord enforces 2 per 10 minutes per channel **per bot**. 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`.
- Modal submission handling
- Autocomplete debouncing
- Batch command registration
### Memory Management
- Minimal cache usage
- Database connection pooling
- Efficient event handlers
- No memory leaks detected
---
## 🐛 Bug Fixes
### Fixed Issues
- Permission handling for /add and /remove
- Modal form validation
- Priority validation and defaults
- Autocomplete edge cases
- Close confirmation race conditions
- Database transaction safety
### Prevented Issues
- Injection (Mongoose validation and parameterized usage)
- XSS in modal inputs (validation)
- Duplicate tag creation (Mongoose unique index)
- Invalid priority values (validation)
- Race conditions (proper locking)
---
## 📈 Metrics & Logging
### Logged Events
- Ticket creation (both email and Discord)
- Ticket transfers
- Ticket moves
- Priority changes
- Tag usage
- Auto-close actions
- Auto-unclaim actions
- Panel interactions
- Command usage
### Log Channels
- Logging channel (CONFIG.LOG_CHAN)
- Transcript channel (CONFIG.TRANSCRIPT_CHAN)
- Console output
---
## 🔐 Security Enhancements
### Permission Checks
- Staff role validation for /transfer
- Channel permissions for /add and /remove
- Admin-only panel creation
- Ephemeral sensitive messages
### Input Validation
- Tag names (alphanumeric, length limits)
- Priority values (enum validation)
- Modal input sanitization
- Mongoose schema validation
### Error Handling
- Graceful failures
- User-friendly error messages
- Detailed console logging
- No sensitive data exposure
---
## 📚 Documentation
### Created Files
1. **PHASE_FEATURES.md** (3,500+ lines)
- Complete feature documentation
- Configuration reference
- Troubleshooting guide
- Best practices
2. **QUICKSTART.md** (200+ lines)
- 10-step getting started
- Common issues
- Pro tips
- Quick reference
3. **IMPLEMENTATION_SUMMARY.md** (This file)
- Overview of changes
- Statistics
- Technical details
---
## 🧪 Testing Recommendations
### Manual Testing Checklist
- [ ] All commands appear in Discord
- [ ] Tag creation and usage
- [ ] Panel button interaction
- [ ] Modal form submission
- [ ] Close confirmation flow
- [ ] Priority changes reflect in DB
- [ ] Transfer updates claimed_by
- [ ] Move changes channel parent
- [ ] Variables render correctly
- [ ] Autocomplete shows tags
- [ ] /help displays correctly
- [ ] Auto-unclaim runs (if enabled)
- [ ] Background jobs don't crash
### Edge Cases to Test
- Invalid priority values
- Non-existent tags
- Transfer to non-staff
- Move to invalid category
- Empty modal fields
- Special characters in tags
- Very long tag content
- Rapid button clicks
- Multiple tickets simultaneously
---
## 🔮 Future Enhancement Opportunities
### Immediate Opportunities
1. **Statistics Dashboard** - Track usage metrics
2. **Feedback System** - User ratings after close
3. **Web Interface** - View tickets in browser
4. **API Endpoints** - External integrations
### Medium-Term
1. **Advanced Automation** - Rule builder UI
2. **Ticket Templates** - Pre-filled forms
3. **SLA Tracking** - Response time monitoring
4. **Multi-language** - i18n support
### Long-Term
1. **Machine Learning** - Auto-categorization
2. **Voice Tickets** - Voice channel integration
3. **Mobile App** - React Native client
4. **Analytics** - Business intelligence
---
## 🎓 Key Learnings
### Technical Insights
- Modal forms are powerful for data collection
- Variables system enables flexible messaging
- Background jobs require careful scheduling
- Autocomplete enhances UX significantly
- Database migrations need planning
### Best Practices Applied
- Mongoose queries (no raw string concatenation)
- Clear error messages
- Comprehensive logging
- Graceful degradation
- Configuration over hardcoding
### Patterns Used
- Factory pattern for variables
- Observer pattern for events
- Strategy pattern for automation
- Builder pattern for embeds/modals
- Repository pattern for database
---
## 📝 Migration Guide
### From Previous Version
#### 1. Update Code
```bash
git pull origin main
npm install
```
#### 2. Update .env
Add new variables:
```env
CLAIM_TIMEOUT_ENABLED=false
AUTO_UNCLAIM_ENABLED=false
ALLOW_CLAIM_OVERWRITE=false
USE_THREADS=false
```
#### 3. Restart Bot
```bash
npm start
```
MongoDB collections are created as needed on startup.
#### 4. Register Commands
Commands auto-register on bot ready event.
May take up to 1 hour for Discord to sync.
#### 5. Test New Features
- Create a test tag
- Try the panel system
- Test modal forms
- Verify close confirmation
#### 6. Train Staff
- Share QUICKSTART.md
- Demonstrate new commands
- Explain variables
- Show panel usage
---
## 🎉 Conclusion
Successfully delivered a comprehensive ticket system upgrade with:
- ✅ All requested features implemented
- ✅ No breaking changes
- ✅ Zero linter errors
- ✅ Complete documentation
- ✅ Production-ready code
- ✅ Scalable architecture
**Status: READY FOR PRODUCTION** 🚀
---
## 📞 Support
### If Issues Arise
1. Check logs for error messages
2. Review PHASE_FEATURES.md
3. Verify .env configuration
4. Test in isolated environment
5. Roll back if needed (no DB changes break old code)
### Resources
- `PHASE_FEATURES.md` - Complete documentation
- `QUICKSTART.md` - Quick reference
- `/help` command - In-Discord help
- Console logs - Debug information
---
**Implementation Date**: February 2025
**Version**: 2.0.0
**Status**: Complete ✅
**Stability**: Production Ready 🟢

View File

@@ -1,365 +0,0 @@
# New Features Added to Broccolini Bot
## Overview
This document summarizes the new features added to enhance the ticket management system. Run all commands from the repo root; `.env` lives in the repo root (copy from `.env.example`).
## ✅ Features Implemented
### 1. Auto-Close Automation
**Status:** ✅ Fully Implemented
**Configuration:**
```env
AUTO_CLOSE_ENABLED=true
AUTO_CLOSE_AFTER_HOURS=72
AUTO_CLOSE_MESSAGE=This ticket has been automatically closed due to inactivity.
```
**How it works:**
- Runs every hour (configurable)
- Checks for tickets with no activity for X hours
- Automatically closes inactive tickets
- Sends auto-close message to channel
- Sends close notification email to customer
- Deletes channel after 5 seconds
### 2. Ticket Limits (Global & Per-User)
**Status:** ✅ Fully Implemented
**Configuration:**
```env
GLOBAL_TICKET_LIMIT=5
TICKET_LIMIT_PER_CATEGORY=3
```
**How it works:**
- Checks ticket count before creating new ticket
- Prevents users from exceeding global limit
- Marks email as read if limit reached (prevents retry loop)
- Logs limit violations
### 3. Additional Permission Controls
**Status:** ✅ Fully Implemented
**Configuration:**
```env
BLACKLISTED_ROLES=role_id_1,role_id_2
ADDITIONAL_STAFF_ROLES=role_id_3,role_id_4
```
**How it works:**
- `hasBlacklistedRole()` function checks user roles
- Can be integrated into ticket creation or button interactions
- Ready for expansion (e.g., staff-only commands)
### 4. Welcome & Greeting Messages
**Status:** ✅ Fully Implemented
**Configuration:**
```env
TICKET_WELCOME_MESSAGE=Thank you for contacting Indifferent Broccoli Support! A team member will assist you shortly.
TICKET_CLAIMED_MESSAGE=This ticket has been claimed by {staff_name}.
TICKET_UNCLAIMED_MESSAGE=This ticket is now available for any staff member.
```
**How it works:**
- Welcome message sent when ticket is created (not on reopen)
- Claim message uses `{staff_name}` placeholder (replaced with staff mention)
- Unclaim message sent when ticket is released
### 5. Reminder Messages
**Status:** ✅ Fully Implemented
**Configuration:**
```env
REMINDER_ENABLED=true
REMINDER_AFTER_HOURS=24
REMINDER_MESSAGE=This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.
```
**How it works:**
- Runs every 30 minutes
- Checks for tickets inactive for X hours
- Sends reminder message to channel
- Marks reminder as sent (won't remind again until new activity)
- Resets reminder flag when ticket has new activity
### 6. Priority Levels
**Status:** ✅ Configured, Ready for UI Implementation
**Configuration:**
```env
PRIORITY_ENABLED=true
DEFAULT_PRIORITY=normal
PRIORITY_HIGH_EMOJI=🔴
PRIORITY_MEDIUM_EMOJI=🟡
PRIORITY_LOW_EMOJI=🟢
```
**Database:**
- Added `priority` field to Ticket model (MongoDB; default: 'normal')
**Helper Functions:**
- `getPriorityEmoji(priority)` - Returns emoji for priority level (low, normal, medium, high)
- `getPriorityColor(priority)` - Returns color for embeds
**Slash command `/priority`:**
- Dropdown: low, normal, medium, high (default: normal)
- When set, channel/thread name is prefixed with the priority emoji
- Add priority display in ticket embed
- Add priority filter in ticket queries
### 7. Button & Embed Customization
**Status:** ✅ Fully Implemented
**Configuration:**
```env
# Button Labels
BUTTON_LABEL_CLOSE=Close Ticket
BUTTON_LABEL_CLAIM=Claim
BUTTON_LABEL_UNCLAIM=Unclaim
# Button Emojis
BUTTON_EMOJI_CLOSE=🔒
BUTTON_EMOJI_CLAIM=📌
BUTTON_EMOJI_UNCLAIM=🔓
# Embed Colors (Hex format)
EMBED_COLOR_OPEN=0x00FF00
EMBED_COLOR_CLOSED=0xFF0000
EMBED_COLOR_CLAIMED=0xFFFF00
EMBED_COLOR_ESCALATED=0xFF6600
EMBED_COLOR_INFO=0x1e2124
```
**How it works:**
- All button labels/emojis now use CONFIG values
- Embed colors configurable per state
- Easy to rebrand by changing .env
### 8. Activity Tracking
**Status:** ✅ Fully Implemented
**Database:**
- Added `last_activity` column to tickets table
- Added `reminder_sent` column to tickets table
**How it works:**
- Tracks last message time in ticket
- Updated when Discord messages sent
- Updated when ticket created
- Used for auto-close and reminder timing
- Resets reminder flag on new activity
## 🟡 Features Partially Implemented
### 9. Modal Forms for Ticket Creation
**Status:** 🟡 Framework Ready, Needs UI Implementation
**What's Ready:**
- Database supports priority field
- Config system supports modal questions (placeholder)
- Button interaction handlers in place
**To Complete:**
1. Add `/ticket-create` slash command that shows modal
2. Create modal with questions:
- Issue description (textarea)
- Game selection (dropdown or text input)
- Priority (dropdown: high/normal/low)
3. Handle modal submission
4. Create ticket from modal data
5. Add modal config to .env:
```env
TICKET_FORM_ENABLED=false
TICKET_FORM_QUESTION_1=What is your issue?
TICKET_FORM_QUESTION_2=Which game server is this related to?
```
**Example Implementation Needed:**
```javascript
// In slash command registration
const ticketCreateCommand = new SlashCommandBuilder()
.setName('ticket-create')
.setDescription('Create a support ticket');
// In interaction handler
if (interaction.commandName === 'ticket-create') {
const modal = new ModalBuilder()
.setCustomId('create_ticket_modal')
.setTitle('Create Support Ticket')
.addComponents(
new ActionRowBuilder().addComponents(
new TextInputBuilder()
.setCustomId('issue_description')
.setLabel('Describe your issue')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true)
),
new ActionRowBuilder().addComponents(
new TextInputBuilder()
.setCustomId('game_name')
.setLabel('Which game?')
.setStyle(TextInputStyle.Short)
)
);
await interaction.showModal(modal);
}
```
## 📊 Database Schema Updates
The **Ticket** model in `models.js` (MongoDB/Mongoose) includes these fields:
-`broccolini_ticket_id`
-`priority`
-`last_activity`
-`reminder_sent`
## 🎯 Testing Checklist
### Auto-Close:
- [ ] Create ticket
- [ ] Wait AUTO_CLOSE_AFTER_HOURS (or modify DB `last_activity` to simulate)
- [ ] Verify auto-close message appears
- [ ] Verify email sent
- [ ] Verify channel deleted
### Ticket Limits:
- [ ] Create tickets until limit reached
- [ ] Verify next email doesn't create ticket
- [ ] Verify email marked as read (not retried)
### Welcome Messages:
- [ ] Create new ticket
- [ ] Verify welcome message appears
- [ ] Reopen ticket (reply to email)
- [ ] Verify welcome message does NOT appear on reopen
### Reminders:
- [ ] Create ticket
- [ ] Wait REMINDER_AFTER_HOURS (or modify DB)
- [ ] Verify reminder message sent
- [ ] Send new message
- [ ] Verify reminder can be sent again after new inactivity period
### Activity Tracking:
- [ ] Create ticket, verify `last_activity` set
- [ ] Send message, verify `last_activity` updated
- [ ] Verify `reminder_sent` resets on activity
### Button Customization:
- [ ] Change button labels in .env
- [ ] Restart bot
- [ ] Create ticket
- [ ] Verify new labels appear
### Priority (when UI implemented):
- [ ] Set priority via command
- [ ] Verify emoji shows
- [ ] Verify color changes
## 🔧 Configuration Summary
### Required .env Updates:
Add these lines to your `.env` file (already done):
```env
# AUTO-CLOSE SETTINGS
AUTO_CLOSE_ENABLED=true
AUTO_CLOSE_AFTER_HOURS=72
AUTO_CLOSE_MESSAGE=This ticket has been automatically closed due to inactivity.
# TICKET LIMITS
GLOBAL_TICKET_LIMIT=5
TICKET_LIMIT_PER_CATEGORY=3
# PERMISSION CONTROLS
BLACKLISTED_ROLES=
ADDITIONAL_STAFF_ROLES=
# WELCOME & REMINDER MESSAGES
TICKET_WELCOME_MESSAGE=Thank you for contacting Indifferent Broccoli Support! A team member will assist you shortly.
TICKET_CLAIMED_MESSAGE=This ticket has been claimed by {staff_name}.
TICKET_UNCLAIMED_MESSAGE=This ticket is now available for any staff member.
REMINDER_ENABLED=true
REMINDER_AFTER_HOURS=24
REMINDER_MESSAGE=This ticket has been inactive for {hours} hours. Please provide an update or close the ticket.
# PRIORITY LEVELS
PRIORITY_ENABLED=true
DEFAULT_PRIORITY=normal
PRIORITY_HIGH_EMOJI=🔴
PRIORITY_MEDIUM_EMOJI=🟡
PRIORITY_LOW_EMOJI=🟢
# BUTTON CUSTOMIZATION
BUTTON_LABEL_CLOSE=Close Ticket
BUTTON_LABEL_CLAIM=Claim
BUTTON_LABEL_UNCLAIM=Unclaim
BUTTON_EMOJI_CLOSE=🔒
BUTTON_EMOJI_CLAIM=📌
BUTTON_EMOJI_UNCLAIM=🔓
# EMBED COLORS
EMBED_COLOR_OPEN=0x00FF00
EMBED_COLOR_CLOSED=0xFF0000
EMBED_COLOR_CLAIMED=0xFFFF00
EMBED_COLOR_ESCALATED=0xFF6600
EMBED_COLOR_INFO=0x1e2124
```
## 📝 Next Steps
1. **Test all features** using checklist above
2. **Implement priority UI** (slash command or buttons)
3. **Implement modal forms** for Discord-side ticket creation
4. **Migrate to MongoDB** (use existing schemas in models.js)
5. **Add monitoring** for auto-close/reminder jobs
6. **Consider**: Email notifications when limits reached
7. **Consider**: Dashboard role permissions (currently placeholder)
## 💡 Usage Examples
### Setting Custom Messages:
```env
TICKET_WELCOME_MESSAGE=🎮 Welcome to Indifferent Broccoli Support! Our gaming experts will help you shortly.
TICKET_CLAIMED_MESSAGE={staff_name} is now handling your ticket.
```
### Customizing Colors:
```env
EMBED_COLOR_OPEN=0x00FF00 # Green for open tickets
EMBED_COLOR_CLAIMED=0xFFD700 # Gold for claimed tickets
EMBED_COLOR_ESCALATED=0xFF4500 # Orange-red for escalated
```
### Adjusting Timing:
```env
AUTO_CLOSE_AFTER_HOURS=48 # Close after 2 days
REMINDER_AFTER_HOURS=12 # Remind after 12 hours
```
## 🐛 Known Limitations
1. **Modal forms** not yet implemented (needs slash command + modal handler)
2. **Priority** stored but not displayed or settable via UI
3. **Blacklisted roles** checked in helper function but not enforced in all interactions yet
4. **Auto-close** doesn't distinguish between customer and staff activity (both reset timer)
5. **Ticket limits** don't send notification email (just logs and skips)
## 🎉 Summary
**Fully Working:**
- ✅ Auto-close (8/10 complete - works, needs tuning)
- ✅ Ticket limits (9/10 complete - works, could add email notification)
- ✅ Permission controls (7/10 - helper exists, needs integration)
- ✅ Welcome messages (10/10 complete)
- ✅ Reminder messages (10/10 complete)
- ✅ Button/embed customization (10/10 complete)
- ✅ Activity tracking (10/10 complete)
**Needs Completion:**
- 🟡 Priority UI (5/10 - backend ready, needs slash command)
- 🟡 Modal forms (3/10 - framework ready, needs implementation)
**Overall:** ~85% complete, 15% needs UI work

View File

@@ -1,531 +0,0 @@
# Broccolini Bot - New Features Documentation
This document outlines all the features implemented in the latest update.
---
## Phase 1: Foundation & Core Commands
### 1. Variables System
A powerful template system for dynamic messages using placeholders.
**Available Variables:**
- `{ticket.user}` / `{ticket.creator}` - Ticket creator username
- `{ticket.email}` - Customer email address
- `{ticket.number}` - Ticket number
- `{ticket.subject}` - Ticket subject line
- `{ticket.claimed}` - "Yes" or "No"
- `{ticket.claimedby}` - Staff member name or "Unclaimed"
- `{ticket.priority}` - Ticket priority level
- `{staff.user}` - Staff username
- `{staff.name}` - Staff display name
- `{staff.mention}` - Staff mention (@user)
- `{server.name}` - Discord server name
- `{server.membercount}` - Server member count
- `{hours}` - Hours (for auto-messages)
- `{date}` - Current date
- `{time}` - Current time
**Usage:** Variables work in tags, welcome messages, and other customizable messages.
---
### 2. Tags/Saved Responses System
Create, manage, and use saved responses for common questions.
**Commands:**
- `/response send <name>` - Send a saved response
- `/response create <name> <content>` - Create new saved response
- `/response edit <name> <content>` - Edit existing saved response
- `/response delete <name>` - Delete a saved response
- `/response list` - List all saved responses
- `/tag` - Set ticket category (dropdown: Server Down, Billing, Mod Help, etc.); posts categorization message (channel name unchanged)
**Features:**
- Autocomplete support for response names
- Usage counter tracking
- Supports variable substitution
- Database-backed persistence (MongoDB via Mongoose `Tag` model; see `models.js`)
---
### 3. User Management Commands
#### `/add @user`
Add a user to the current ticket thread.
**Permissions:** Sets ViewChannel, SendMessages, and ReadMessageHistory for the user.
#### `/remove @user`
Remove a user from the current ticket thread.
**Behavior:** Deletes the permission overwrite for the user.
---
### 4. `/help` Command
Displays a comprehensive embed with all available commands, organized by category.
**Categories:**
- User Management
- Ticket Management
- Tags (Saved Responses)
- Variables
- Panel System
- Other
---
## Phase 2: Ticket Management
### 1. `/transfer @staff [reason]`
Transfer a ticket to another staff member.
**Features:**
- Validates target has staff role
- Updates claimed_by in database
- Logs to logging channel
- Optional reason parameter
---
### 2. `/move #category`
Move a ticket to a different category.
**Features:**
- Preserves permissions (lockPermissions: true)
- Logs the move
- Works with any category channel
---
### 3. `/force-close`
Force close a ticket without confirmation.
**Features:**
- Generates transcript
- Updates the external ticket API (if configured)
- Archives channel after 5 seconds
- No confirmation required
---
### 4. Close Confirmation System
When clicking the "Close Ticket" button, users now see a confirmation prompt.
**Flow:**
1. User clicks "Close Ticket"
2. Confirmation buttons appear (ephemeral)
3. User clicks "Confirm Close" or "Cancel"
4. If confirmed, ticket closes as usual
**Storage:** Close requests are stored in MongoDB. See `models.js` for schema.
---
### 5. `/topic <text>`
Set the channel topic/description for a ticket.
**Use Cases:**
- Document ticket status
- Add important notes
- Set expectations
---
## Phase 3: UX Enhancements
### 1. Enhanced Claiming System
#### Claim Overwrite
**Config:** `ALLOW_CLAIM_OVERWRITE=true/false`
When enabled, allows staff to claim tickets already claimed by someone else.
**Behavior:**
- If disabled: Shows error message when trying to claim someone else's ticket
- If enabled: Allows claim overwrite, updates claimed_by
#### Auto-Unclaim on Inactivity
**Config:**
- `AUTO_UNCLAIM_ENABLED=true/false`
- `AUTO_UNCLAIM_AFTER_HOURS=24`
Automatically unclaims tickets after specified hours of inactivity.
**Features:**
- Checks every hour
- Based on last_activity timestamp
- Sends notification message in channel
- Resets claimed_by to NULL
#### Claim Timeout
**Config:**
- `CLAIM_TIMEOUT_ENABLED=true/false`
- `CLAIM_TIMEOUT_HOURS=48`
Set a maximum time for claims (future enhancement placeholder).
---
### 2. Modal Forms for Ticket Creation
Users can create Discord-side tickets through an interactive modal form.
**Form Fields:**
- Subject (required, short text, max 100 chars)
- Description (required, paragraph, max 1000 chars)
- Priority (optional, low/normal/high)
**Workflow:**
1. User clicks "Open Ticket" button on panel
2. Modal appears with form fields
3. User fills out and submits
4. Bot creates ticket channel automatically
**Features:**
- Validates priority input
- Auto-generates ticket numbers
- Sets proper permissions
- Sends welcome message
- Logs creation
---
### 3. Priority System
#### `/priority <level>`
Set ticket priority via dropdown: **low**, **normal**, **medium**, or **high** (default: normal).
**Features:**
- Dropdown choices: 🟢 Low, 🟡 Normal, 🟠 Medium, 🔴 High
- When priority is set, the channel/thread name is prefixed with the priority emoji
- Color-coded embeds
- Database-backed
- Visible in ticket embeds
**Priority Colors:**
- High: Red (#FF0000)
- Normal / Medium: Info color
- Low: Green (#00FF00)
**Configuration:**
```env
PRIORITY_ENABLED=true
DEFAULT_PRIORITY=normal
PRIORITY_HIGH_EMOJI=🔴
PRIORITY_MEDIUM_EMOJI=🟡
PRIORITY_LOW_EMOJI=🟢
```
---
## Phase 4: Panel & Category System
### 1. Panel System
#### `/panel #channel [title] [description]`
Create a ticket panel that users can interact with to open tickets.
**Features:**
- Customizable title and description
- "Open Ticket" button
- Sends modal form on click
- Creates Discord-only tickets
**Example Panel:**
```
Title: Open a Support Ticket
Description: Click the button below to create a new support ticket.
A staff member will assist you shortly.
[🎫 Open Ticket]
```
---
### 2. Discord-Side Tickets
Tickets created through panels are Discord-only (no email integration).
**Features:**
- Stored in same tickets table
- gmail_thread_id uses format: `discord-{timestamp}-{userId}`
- sender_email contains Discord tag
- Full feature parity with email tickets
---
### 3. Category System
The bot now supports multiple categories through the `/move` command.
**Features:**
- Move tickets between categories
- Preserves permissions
- Works with both email and Discord tickets
---
### 4. Thread-Style Tickets
**Config:**
- `USE_THREADS=true/false`
- `THREAD_PARENT_CHANNEL=<channel_id>`
When enabled, creates tickets as threads instead of channels.
**Benefits:**
- Cleaner server structure
- No channel limit concerns
- Better organization
**Note:** Implementation ready for future activation.
---
## Phase 5: Automation (Future Enhancement)
### Automation Rules Engine
A framework for creating custom automation rules.
**Planned Features:**
- Trigger-based actions
- Condition matching
- Custom workflows
- Schedule support
**Example Rules:**
- Auto-assign based on keywords
- Auto-tag based on content
- Auto-escalate high priority
- Auto-move based on game/topic
**Note:** Foundation in place, specific rules to be implemented based on needs.
---
## Database Schema
The project uses MongoDB. Ticket, tag, and close-request data are defined in `models.js`. See that file and `MONGODB_SETUP.md` for schema reference.
---
## Configuration Reference
### New Environment Variables
```env
# --- CLAIMING OPTIONS ---
CLAIM_TIMEOUT_ENABLED=false
CLAIM_TIMEOUT_HOURS=48
AUTO_UNCLAIM_ENABLED=false
AUTO_UNCLAIM_AFTER_HOURS=24
ALLOW_CLAIM_OVERWRITE=false
# --- THREAD-STYLE TICKETS ---
USE_THREADS=false
THREAD_PARENT_CHANNEL=
```
---
## Complete Command Reference
### User Management
- `/add @user` - Add user to ticket
- `/remove @user` - Remove user from ticket
### Ticket Management
- `/transfer @staff [reason]` - Transfer ticket to another staff member
- `/move #category` - Move ticket to another category
- `/force-close` - Force close without confirmation
- `/topic <text>` - Set channel topic
- `/priority <level>` - Set ticket priority (low/normal/medium/high); renames channel with priority emoji
- `/escalate [reason] [tier]` - Escalate ticket to tier 2 or 3
- `/deescalate` - De-escalate ticket
### Tags / Saved Responses
- `/response send <name>` - Send saved response
- `/response create <name> <content>` - Create new saved response
- `/response edit <name> <content>` - Edit existing saved response
- `/response delete <name>` - Delete saved response
- `/response list` - List all saved responses
- `/tag` - Set ticket category (dropdown)
### Panel System
- `/panel #channel [title] [description]` - Create ticket panel
### Help
- `/help` - Show all commands
---
## Migration Notes
### From Previous Version
1. **Database**: New Mongoose schema fields used on startup (collections created as needed)
- priority, last_activity, reminder_sent on Ticket
2. **New collections**: Created automatically by Mongoose
- Tag (saved responses)
- CloseRequest
3. **Environment Variables**: Add to `.env` (repo root):
```env
CLAIM_TIMEOUT_ENABLED=false
AUTO_UNCLAIM_ENABLED=false
ALLOW_CLAIM_OVERWRITE=false
USE_THREADS=false
```
4. **No Breaking Changes**: All existing functionality preserved
---
## Best Practices
### Tags
- Use descriptive names (e.g., `welcome`, `closing`, `escalation-info`)
- Include variables for personalization
- Keep content concise but helpful
- Review and update regularly
### Priority System
- Set priority early in ticket lifecycle
- Use high priority sparingly
- Review priority regularly
- Consider SLA based on priority
### Panels
- Place in dedicated support channels
- Use clear, welcoming language
- Include instructions
- Monitor for spam/abuse
### Claiming
- Enable auto-unclaim to prevent stale claims
- Set reasonable timeout periods
- Use overwrite cautiously
- Communicate with team about transfers
---
## Troubleshooting
### Commands Not Appearing
- Verify `DISCORD_APPLICATION_ID` is set
- Check bot has application.commands scope
- Wait up to 1 hour for Discord to sync
- Restart bot after .env changes
### Modal Not Showing
- Ensure user has Create Posts permission
- Check for Discord outages
- Verify bot has proper permissions
### Saved Responses Not Working
- Check MongoDB connection and permissions
- Use `/response list` to confirm saved response exists
- Check for errors in saved response content
### Priority Not Updating
- Verify ticket exists in database
- Check PRIORITY_ENABLED is true
- Ensure valid priority value (low/normal/high)
---
## Performance Considerations
### Database
- MongoDB (Mongoose) for all persistent data
- Regular backups recommended
- Run from repo root; `.env` in repo root
### Auto-Checks
- Auto-close: Runs every hour
- Auto-unclaim: Runs every hour
- Reminders: Runs every 30 minutes
- Adjust intervals in code if needed
### Rate Limits
- Channel creation: 50/day per guild
- Channel rename: Discord enforces 2 per 10 minutes per channel **per bot** ([Discord docs](https://discord.com/developers/docs/topics/rate-limits)). 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`.
- Message edits: Be cautious with bulk operations
---
## Future Enhancements
### Planned Features
1. **Statistics Dashboard**: Track ticket metrics
2. **Feedback System**: Collect user ratings
3. **Advanced Automations**: Rule builder UI
4. **Ticket Templates**: Pre-filled forms
5. **SLA Tracking**: Response time monitoring
6. **Multi-language**: Localization support
7. **Web Dashboard**: View tickets in browser
8. **API Endpoints**: External integrations
### Community Requests
- Custom ticket categories per game
- User blacklist system
- Scheduled availability hours
- Ticket assignment rotation
- Knowledge base integration
---
## Support & Contributing
### Getting Help
- Check documentation first
- Review troubleshooting section
- Check logs for error messages
- Test with minimal configuration
### Reporting Bugs
Include:
- Steps to reproduce
- Expected behavior
- Actual behavior
- Environment details
- Log excerpts
### Feature Requests
Consider:
- Use case description
- Priority/importance
- Potential workarounds
- Similar existing features
---
## Changelog
### v2.0.0 - Major Feature Update
**Added:**
- Variables system for dynamic messages
- Tags/saved responses system
- User management commands (/add, /remove)
- Transfer, move, force-close commands
- Close confirmation flow
- Enhanced claiming (overwrite, auto-unclaim)
- Modal forms for ticket creation
- Priority system
- Panel system for Discord tickets
- Thread-style tickets option
- Comprehensive /help command
**Improved:**
- Database schema with new fields
- Permission handling
- Error messages
- Logging
**Fixed:**
- Various edge cases
- Permission issues
- Database constraints
---
*Last Updated: 2025*
*Version: 2.0.0*

View File

@@ -1,72 +0,0 @@
# Broccolini Bot Roadmap & Proposal
Short proposal and possible next steps for the Broccolini Bot ticketing system. Discord + Gmail + MongoDB remain the core; any extension is additive.
---
## Current State
- **Email → Discord:** Gmail poll creates ticket channels/threads; replies sync back to Gmail.
- **Discord-first:** Panels, slash commands, buttons, modals, context menus for full ticket lifecycle (claim, close, escalate, tag, priority, transfer, move, saved responses).
- **MongoDB:** Single data store for tickets, transcripts, tags, close requests, guild settings, and (optional) account info.
- **Automation:** Auto-close, reminders, auto-unclaim, claim timeout; all configurable via `.env`.
- **Security:** HTML escaping in outbound emails; test env workflow; optional healthcheck host binding.
No external ticketing API (e.g. Zammad) is used; the bot is self-contained.
---
## Possible Next Steps
### 1. Read-only API layer
- Expose ticket and metadata via a **read-only** HTTP API (e.g. alongside the bot or a small separate service).
- Endpoints: list/filter tickets, ticket by ID, “my tickets” by Discord ID, tags, guild settings.
- Enables dashboards, mobile tools, or a **Support Cockpit** (bOSScord-style overlay) without changing Discord or bot behavior.
- Optional: use something like Directus on top of MongoDB for instant REST/GraphQL and admin UI.
### 2. Ticket routing & queues
- Derive a **queue** (or routing bucket) per ticket from game detection (`GAME_LIST`), subject/body keywords, and existing tags.
- Store queue on the ticket document; show it in Discord (e.g. in embeds or channel name) and in any future API/UI.
- Enables “Network”, “Billing”, “Mod Help”, “Game X” views without changing how staff use Discord.
### 3. Incident & problem tracking
- **Incident:** one-off “something broke” ticket.
- **Problem:** recurring issue; link multiple incidents, track root cause, workaround, and fix status.
- Optional new commands or buttons to “Link to problem” / “Create problem from ticket” and optional API fields.
### 4. Knowledge base & PM links
- Internal KB (e.g. Wiki.js): link from problems/tickets to articles; optional `/kb search` in Discord.
- PM tool (Plane, Focalboard, Taiga): “Create PM task from ticket” and link ticket ↔ task.
- Broccolini Bot stays the source of truth for tickets; KB and PM are linked data.
### 5. bOSScord / Support Cockpit
- Web (later desktop) client that **reads** from the API and MongoDB.
- Richer views: queues, SLA-style status, “whos viewing this ticket”, virtual display names, links to KB and PM.
- All writes and communication remain in Discord; the client is view/routing only.
### 6. GitHub / GitLab (optional)
- From a problem or PM task: create issue/PR with context.
- Webhooks to update problem/task when issues close or PRs merge.
---
## Principles
- **Discord + Broccolini Bot are canonical.** New features augment, they dont replace, the current flow.
- **API-first for new UIs.** Any dashboard or cockpit consumes a read-only (or narrowly write) API; no direct DB access from frontends.
- **Config and secrets stay in `.env`.** New services get their own env or reuse existing vars where it makes sense.
- **MongoDB remains the primary store.** New collections or fields as needed; no second database unless justified.
---
## No Deadlines
This document is a proposal and idea list. Work can proceed in small steps: e.g. add a read-only ticket API, then add queue derivation, then plug in a simple dashboard or bOSScord.
For a fuller platform vision (bOSScord, queues, incidents/problems, KB, PM), see the parent repos bOSScord proposal if present.

View File

@@ -1,352 +0,0 @@
# 🎉 Discord API Improvements - COMPLETE!
## ✅ All 12 Improvements Successfully Implemented
---
## 🚀 Quick Start
### 1. Restart Your Bot
```bash
npm start
```
### 2. Commands Will Auto-Register
Wait up to 1 hour for Discord to fully sync all commands.
### 3. Try New Features
#### For Staff:
```
/search query:test status:open
/response list
Right-click any message → "Create Ticket From Message"
Right-click any user → "View User Tickets"
```
#### For Admins:
```
/stats
```
#### For Everyone:
Set priority with `/priority` (dropdown: low, normal, medium, high); channel name gets the priority emoji.
---
## 📊 What Changed
### Commands
- **Before:** 15 commands
- **After:** 13 slash commands + 2 context menu commands = 15 total
- Saved responses: `/response send`, `/response create`, etc.; ticket category: `/tag` (dropdown).
### New Features
- ✅ Search command with filters
- ✅ Stats command with analytics
- ✅ Context menu commands (right-click)
- ✅ Priority selection buttons
- ✅ Tag delete confirmation
- ✅ Loading states everywhere
- ✅ Error tracking & monitoring
- ✅ Thread-style tickets support
### Improvements
- ✅ Context restrictions (guild-only commands)
- ✅ Permission checks (staff-only visibility)
- ✅ String length validation (10-500 chars, etc.)
- ✅ Better organization (grouped tag commands)
---
## 🎯 Key New Commands
### `/search <query> [status]`
Search tickets by email, subject, or number.
**Example:**
```
/search query:john@example.com status:open
```
### `/stats`
View bot analytics and performance metrics.
**Shows:**
- Bot uptime
- Total interactions
- Open/closed tickets
- Error rates
- Top commands
### `/response send|create|edit|delete|list` and `/tag`
Saved responses: `/response send`, `/response create`, `/response edit`, `/response delete`, `/response list`. Use `/tag` (dropdown) to set ticket category (Server Down, Billing, Mod Help, etc.); the bot posts a categorization message.
---
## 🖱️ Context Menu Commands
### Create Ticket From Message
1. Right-click any message
2. Apps → "Create Ticket From Message"
3. Ticket created with message content!
### View User Tickets
1. Right-click any user
2. Apps → "View User Tickets"
3. See all their tickets instantly!
---
## 🎨 Priority (slash command only)
Set ticket priority with `/priority` (dropdown: low, normal, medium, high). The channel/thread name is prefixed with the priority emoji (🟢 🟡 🟠 🔴). No priority buttons are shown on tickets; use the command only.
---
## 🧵 Thread-Style Tickets (Optional)
Want tickets as threads instead of channels?
**Enable in `.env`:**
```env
USE_THREADS=true
THREAD_PARENT_CHANNEL=<your_channel_id>
```
**Benefits:**
- Cleaner server structure
- Auto-archive after 24h
- No channel limit issues
- Perfect for high volume
---
## 📈 Analytics & Monitoring
### What's Tracked
- Every command used
- Every button clicked
- Every modal submitted
- Every error that occurs
### View Analytics
```
/stats
```
### Console Output
```
📊 Analytics: commands/search by User#1234
❌ Error tracked: tag-create: UNIQUE constraint failed
⚠️ HIGH ERROR RATE: 6.5% in last hour
```
---
## 🔒 Permission System
### Who Sees What
**Everyone:**
- `/help` (works everywhere including DMs)
**Staff (Manage Messages):**
- `/add`, `/remove`
- `/transfer`
- `/search`
- `/escalate`
- `/deescalate`
- Context menu commands
**Staff (Manage Channels):**
- `/move`
- `/force-close`
- `/panel`
**Administrators:**
- `/stats`
---
## ✨ UX Improvements
### Loading States
Commands show "thinking..." indicator:
- `/search` - While searching database
- `/stats` - While calculating metrics
- `/tag list` - While fetching tags
- Context menus - While processing
### Confirmations
Destructive actions require confirmation:
- **Tag delete:** Shows Yes/Cancel buttons
- **Ticket close:** Shows Confirm/Cancel buttons
### Validation
Better error messages:
- Reason too short? "Must be at least 10 characters"
- Tag name taken? "Tag already exists"
- Channel not found? Clear, actionable message
---
## 📋 Migration Checklist
- [x] Code updated with all improvements
- [x] No breaking changes
- [x] All existing features preserved
- [x] New commands added
- [x] Context menu commands added
- [x] Analytics system integrated
- [x] Error tracking enabled
- [x] Documentation complete
### To Deploy:
1. ✅ Backup database (optional but recommended)
2. ✅ Restart bot: `npm start`
3. ✅ Test new commands
4. ✅ Try context menus
5. ✅ Check `/stats`
---
## 🐛 Known Issues
**None!** All features tested and working.
### If Issues Arise:
1. Check console for error messages
2. Verify bot permissions
3. Wait for command sync (up to 1 hour)
4. Review `DISCORD_API_IMPROVEMENTS.md`
---
## 📚 Documentation
### Created/Updated Files:
1. **DISCORD_API_IMPROVEMENTS.md** - Detailed feature documentation
2. **UPGRADE_COMPLETE.md** - This file (quick reference)
3. **DISCORD_API_VALIDATION.md** - Original validation report
4. **broccolini-discord.js** - Updated with all features
### Read These:
- **QUICKSTART.md** - Getting started guide
- **PHASE_FEATURES.md** - Previous features reference
- **IMPLEMENTATION_SUMMARY.md** - Technical overview
---
## 🎯 Test Plan
### Basic Tests
- [x] Run `/help` - Should work
- [x] Run `/response list` - Shows saved responses
- [x] Run `/stats` - Shows analytics
- [x] Run `/search query:test` - Searches tickets
- [x] Run `/priority` in a ticket channel - Changes priority and renames channel with emoji
- [x] Right-click message - Shows context menu
- [x] Right-click user - Shows context menu
- [x] Try `/response delete` - Shows confirmation
### Staff Commands
- [x] All staff commands only visible to staff
- [x] Regular users can't see them
- [x] Permission checks work
### Analytics
- [x] Console shows interaction tracking
- [x] `/stats` displays metrics
- [x] Error tracking works
---
## 💡 Tips for Your Team
### For Staff
1. Use `/search` to find tickets quickly
2. Right-click messages to create tickets
3. Use `/priority` (dropdown: low, normal, medium, high); channel name is prefixed with the priority emoji
4. Create tags for common responses
### For Admins
1. Check `/stats` daily
2. Monitor error rates
3. Review top commands
4. Identify unused features
### For Everyone
1. Use `/help` to see all commands
2. Commands now grouped (cleaner!)
3. Loading states show bot is working
4. Confirmations prevent accidents
---
## 🏆 Achievement Unlocked!
**100% Discord API Compliance**
**All Best Practices Implemented**
**Professional-Grade Bot**
**Production Ready**
**Stats:**
- 12/12 Improvements Complete
- 800+ Lines of Code Added
- 2 New Context Menu Commands
- 5 /response subcommands (send, create, edit, delete, list)
- Full Analytics System
- Comprehensive Error Tracking
---
## 🚀 What's Next?
**You're done!** All requested features implemented.
**Optional Future Ideas:**
1. Add more context menu commands
2. Build web dashboard
3. Add localization (multiple languages)
4. Create automation rules engine
5. Export analytics to CSV
---
## 📞 Support
### Resources
- Discord API Docs: https://discord.com/developers/docs
- Discord.js Guide: https://discordjs.guide/
- Your documentation files (listed above)
### Questions?
Check:
1. `/help` command in Discord
2. DISCORD_API_IMPROVEMENTS.md
3. Console logs for errors
4. `/stats` for bot health
---
**Version:** 3.0.0
**Release Date:** February 2025
**Status:** Production Ready ✅
---
# 🎊 Congratulations!
Your ticket system is now:
- ✅ Modern
- ✅ Feature-rich
- ✅ Professional
- ✅ Analytics-powered
- ✅ Best-practices compliant
**Enjoy your upgraded bot!** 🚀
---
*P.S. Use `/priority` on a ticket channel to set low, normal, medium, or high the channel name will show the priority emoji.*

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
# Environment, Test Env, and Security
## Test environment (prevent data loss)
**.env is production/live.** Changes to `.env` can affect real tickets, Discord, Gmail, and MongoDB. To try config changes safely:
1. **Copy the test template:**
`cp .env.test.example .env.test`
2. **Edit `.env.test`** with test-only values (e.g. test guild, test MongoDB database name, test API URL). Use a separate test DB in `MONGODB_URI` to avoid touching production data.
3. **Run the bot with the test env:**
`npm run start:test`
Or: `ENV_FILE=.env.test node broccolini-discord.js`
4. **Other scripts with test env:**
- `npm run test-mongodb:test` — test MongoDB connection using `.env.test`
5. **After confirming behavior**, migrate only the desired variables from `.env.test` into `.env` (manually). Do not overwrite `.env` blindly.
**Rule:** New or risky env changes are done in `.env.test` first; only after confirmation are they applied to `.env`.
---
## Agent / AI rules
- **Changes to `.env` by an agent (e.g. Cursor) must require explicit user confirmation.** Do not modify `.env` automatically. Prefer proposing changes to `.env.test` or listing the exact edits for the user to apply to `.env`.
- **Do not commit `.env` or `.env.test`.** Only `.env.example` and `.env.test.example` are committed (no secrets).
---
## Security checklist
- **Secrets:** All secrets live in `.env` (or `.env.test` for test). Never commit them. `.gitignore` excludes `.env` and `.env.*` except `.env.example` and `.env.test.example`.
- **Code:** No `eval()` or `new Function()` of user input. No hardcoded tokens, passwords, or API keys in source.
- **Config:** Credentials are read from `process.env` via `config.js`; config is loaded once at startup from the file specified by `ENV_FILE` or default `.env`.
- **MongoDB:** Use a dedicated user and database; bind Mongo to loopback or docker network only; firewall 27017 from public interfaces. For test, use a separate DB or cluster.
- **Discord / Google:** Use tokens with minimal required scopes; rotate if compromised.
- **HTML in emails:** `LOGO_URL`, `EMAIL_SIGNATURE`, and closure messages are escaped in outbound HTML to prevent injection.
- **Healthcheck:** Optional `HEALTHCHECK_HOST=127.0.0.1` in `.env` binds the healthcheck server to localhost only; omit to listen on all interfaces.
- **Dependencies:** Run `npm audit` periodically and fix or accept risk for reported vulnerabilities.
---
## Cleanup and redundancy
- **Single source of truth for env keys:** `.env.example` and `.env.test.example` list all supported variables. Defaults for optional vars live in `config.js`; do not duplicate default values in both `.env.example` and `config.js` for the same value (`.env.example` documents, `config.js` implements).
- **No duplicate env files:** Use `.env` for live, `.env.test` for test; do not commit `.env.local`, `.env.production`, etc. unless documented and gitignored as needed.
- **Parent repo (IB-Discord-Bot):** Broccolini Bot does not reference sibling paths (e.g. `../ngrok`) in code. Run order and ports are documented in `~/IB-Discord-Bot/README.md`.
---
## Connection to IB-Discord-Bot stack
Broccolini Bot is a subproject of **IB-Discord-Bot**. It does not import or require files outside `broccolini-bot/`. Integration is via:
- **Ports:** Broccolini Bot healthcheck uses `DISCORD_ONLY_PORT` (default 5000). Use a different port in `.env.test` (e.g. 5001) if running bot and test bot on the same machine.
See parent **~/IB-Discord-Bot/README.md** for run order, ports, and troubleshooting.
---
## Quick reference
| File / command | Purpose |
|-----------------------|--------|
| `.env` | Live config (never commit). |
| `.env.test` | Test config (never commit). |
| `.env.example` | Template for `.env` (committed). |
| `.env.test.example` | Template for `.env.test` (committed). |
| `ENV_FILE=.env.test` | Load `.env.test` instead of `.env`. |
| `npm run start:test` | Run bot with `.env.test`. |
| `npm run test-mongodb:test` | Test MongoDB using `.env.test`. |

View File

@@ -1,166 +0,0 @@
# MongoDB Setup for Broccolini Bot
## Overview
Broccolini Bot uses **MongoDB only** for persistent storage (tickets, transcripts, counters, tags, close requests). Run all commands from the repo root; create `.env` there (copy from `.env.example`) and set `MONGODB_URI`. For test runs, use `.env.test` (copy from `.env.test.example`) and `npm run test-mongodb:test`; see [ENV_AND_SECURITY.md](./ENV_AND_SECURITY.md).
## Files
1. **`db-connection.js`** - MongoDB connection module with reconnection logic
2. **`models.js`** - Mongoose schemas including:
- `Ticket` - Stores ticket information
- `TicketCounter` - Tracks ticket numbers per sender
- `Transcript` - Stores transcript message references
3. **`scripts/test-mongodb.js`** - Connection test script (run via `npm run test-mongodb`; use `npm run test-mongodb:test` with `.env.test`)
## Configuration
### 1. Environment Variable
Add to your `.env` file:
```env
MONGODB_URI=mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db
```
**Note:** Mongo runs self-hosted on the same host as the bot. A **dedicated user per database** is required — create `broccoli_bot` with `readWrite` on `broccoli_db` only (no admin/root, no cross-DB access). For test, create a separate user with `readWrite` on `broccoli_db_test` only.
Example mongosh setup:
```javascript
use broccoli_db
db.createUser({
user: "broccoli_bot",
pwd: "CHANGE_ME",
roles: [ { role: "readWrite", db: "broccoli_db" } ]
})
```
Bind Mongo to loopback (`bindIp: 127.0.0.1`) or the internal docker network only; firewall `27017` from public interfaces.
### 2. Install Dependencies
```bash
npm install
```
This will install `mongoose@^6.12.0`.
## Usage in Your Code
### Basic Connection
```javascript
const { connectMongoDB, closeMongoDB, mongoose } = require('./db-connection');
// In your Discord client.once('ready', ...) event:
await connectMongoDB(process.env.MONGODB_URI);
console.log('Connected to MongoDB');
// Get models:
const Ticket = mongoose.model('Ticket');
const TicketCounter = mongoose.model('TicketCounter');
const Transcript = mongoose.model('Transcript');
```
## Schema Reference
### Ticket Schema
```javascript
{
gmail_thread_id: String (required, unique, indexed),
discord_thread_id: String,
broccolini_ticket_id: Number,
sender_email: String (required),
subject: String,
created_at: Date (default: now),
status: String (enum: ['open', 'closed'], default: 'open'),
claimed_by: String (Discord user ID),
escalated: Boolean (default: false),
ticket_number: Number,
rename_count: Number (default: 0), // orphan: no longer read/written (see CLAUDE.md)
rename_window_start: Date // orphan: no longer read/written
}
```
### TicketCounter Schema
```javascript
{
sender_local: String (required, unique),
counter: Number (default: 1)
}
```
### Transcript Schema
```javascript
{
gmail_thread_id: String (required),
transcript_message_id: String,
created_at: Date (default: now)
}
```
## Testing the Connection
From the repo root, run:
```bash
npm run test-mongodb
```
Expected output:
```
Pinged your deployment. You successfully connected to MongoDB!
```
## Graceful Shutdown
Add this to your main file for clean shutdown:
```javascript
process.on('SIGTERM', async () => {
console.log('SIGTERM received, closing connections...');
await closeMongoDB();
await client.destroy(); // Discord client
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, closing connections...');
await closeMongoDB();
await client.destroy();
process.exit(0);
});
```
## Connection Features
- **Auto-reconnection**: If MongoDB connection drops, Mongoose will automatically attempt to reconnect
- **Connection events**: Logs when connected, disconnected, and reconnected
- **Error handling**: Graceful error messages with stack traces
- **Timeouts**: Configured with reasonable defaults (5s server selection, 45s socket timeout)
## Next Steps
1. Review the schemas in `models.js`
2. Test the connection with `npm run test-mongodb`
3. Start the bot with `npm start` (uses MongoDB throughout)
4. Monitor MongoDB connection in production logs
## Troubleshooting
### Connection refused
- Check MongoDB is running: `docker ps` or `systemctl status mongodb`
- Verify port 27017 is correct in `.env` (or whatever port your mongod is bound to)
- Check MongoDB logs for errors
### Authentication failed
- Verify the user exists in the correct DB's `authSource` (URI must include `?authSource=broccoli_db`)
- Confirm the user has `readWrite` on `broccoli_db`: `db.getUser("broccoli_bot")` in mongosh
### Schema validation errors
- Check required fields are provided when creating documents
- Ensure `status` is either 'open' or 'closed' (enum validation)

View File

@@ -1,154 +0,0 @@
# Project Structure
Overview of the **Broccolini Bot** project layout and the role of each file and directory. Single-level repo: all paths are relative to the repo root.
---
## Root
| File / Dir | Purpose |
|------------|--------|
| `broccolini-discord.js` | **Entry point.** Main Discord bot process. |
| `config.js` | Configuration loading (env, defaults). |
| `db-connection.js` | MongoDB connection setup. |
| `models.js` | Mongoose models (e.g. guild settings, tickets). |
| `utils.js` | Shared utilities. |
| `gmail-poll.js` | Gmail polling / inbox sync logic. |
| `game-options.json` | Game-related options (e.g. for slash commands). |
| `package.json` | Dependencies and npm scripts. |
| `.env.example` | Example environment variables (copy to `.env`). |
| `.env.test.example` | Test env template (copy to `.env.test`; run with `npm run start:test`). See [ENV_AND_SECURITY.md](./ENV_AND_SECURITY.md). |
| `.gitignore` | Git ignore rules (`.env` and `.env.test` never committed). |
---
## Directories
### `commands/`
Slash-command registration and definitions.
| File | Purpose |
|------|--------|
| `register.js` | Registers Discord slash commands (e.g. `/ticket`, `/setup`). |
---
### `handlers/`
Event and interaction handlers for the Discord bot.
| File | Purpose |
|------|--------|
| `accountinfo.js` | Account / user info commands or logic. |
| `analytics.js` | Analytics or stats handling. |
| `buttons.js` | Discord button interaction handlers. |
| `commands.js` | Slash command execution routing. |
| `messages.js` | Message events (e.g. DMs, channel messages). |
| `setup.js` | Setup / configuration flow (e.g. guild setup). |
---
### `services/`
Core business logic and external integrations.
| File | Purpose |
|------|--------|
| `debugLog.js` | Debug / structured logging. |
| `gmail.js` | Gmail API integration (read/send, labels). |
| `guildSettings.js` | Guild-specific settings (DB + cache). |
| `tickets.js` | Ticket lifecycle (create, update, auto-close, reminders). |
---
### `utils/`
Helper modules used across the app.
| File | Purpose |
|------|--------|
| `ticketComponents.js` | Discord components (buttons, selects) for ticket flows. |
---
### `scripts/`
One-off or maintenance scripts.
| File | Purpose |
|------|--------|
| `backup-env.js` | Copies `.env` to `.env.backup` (run via `node scripts/backup-env.js`). |
| `test-mongodb.js` | Tests MongoDB connection (run via `npm run test-mongodb`). |
---
### `docs/`
Documentation and reference files (all paths below relative to repo root).
| File | Purpose |
|------|--------|
| `ENV_AND_SECURITY.md` | Test env workflow, security checklist, agent rules. |
| `QUICKSTART.md` | Quick start / setup guide. |
| `FEATURES_SUMMARY.md` | Feature overview. |
| `IMPLEMENTATION_SUMMARY.md` | Implementation notes. |
| `PHASE_FEATURES.md` | Phased feature list. |
| `MONGODB_SETUP.md` | MongoDB setup instructions. |
| `NEW_FEATURES.md` | New features changelog. |
| `UPGRADE_COMPLETE.md` | Upgrade completion notes. |
| `DISCORD_API_VALIDATION.md` | Discord API validation details. |
| `DISCORD_API_IMPROVEMENTS.md` | Discord API improvements. |
| `PROPOSAL.md` | Roadmap and possible next steps (API, routing, bOSScord). |
| `PROJECT_STRUCTURE.md` | This file. |
---
## Tree View
```
broccolini-bot/
├── broccolini-discord.js # Entry point
├── config.js
├── db-connection.js
├── models.js
├── utils.js
├── gmail-poll.js
├── game-options.json
├── package.json
├── .env.example
├── .gitignore
├── commands/
│ └── register.js
├── handlers/
│ ├── accountinfo.js
│ ├── analytics.js
│ ├── buttons.js
│ ├── commands.js
│ ├── messages.js
│ └── setup.js
├── services/
│ ├── debugLog.js
│ ├── gmail.js
│ ├── guildSettings.js
│ └── tickets.js
├── utils/
│ └── ticketComponents.js
├── scripts/
│ ├── backup-env.js
│ └── test-mongodb.js
├── docs/ # All .md docs except README.md
│ ├── ENV_AND_SECURITY.md
│ ├── QUICKSTART.md
│ ├── MONGODB_SETUP.md
│ └── ... (see table above)
└── README.md
```
---
## Run
- **Start bot:** `npm start` → runs `node broccolini-discord.js`
- **Backup .env:** `node scripts/backup-env.js` → copies `.env` to `.env.backup`
- **Test MongoDB:** `npm run test-mongodb` → runs `node scripts/test-mongodb.js`

View File

@@ -1,199 +0,0 @@
# Broccolini Bot Quick Start Guide
Get started with Broccolini Bot in 5 minutes! Run all commands from the repo root. Ensure `.env` exists in the repo root (copy from `.env.example`).
**Test env:** To try changes safely, use `.env.test` (copy from `.env.test.example`) and run `npm run start:test`. See [ENV_AND_SECURITY.md](./ENV_AND_SECURITY.md). **Agents:** do not modify `.env` without explicit user confirmation; prefer changing `.env.test` first.
## 1. Restart Your Bot
```bash
npm start
```
The bot will automatically:
- Use MongoDB collections (Tag, CloseRequest, etc.) as needed
- Register all new slash commands
- Start background jobs (auto-close, auto-unclaim, reminders)
## 2. Create Your First Saved Response
```
/response create name:welcome content:Welcome to support, {ticket.user}! We'll help you with {ticket.subject}.
```
Then use it:
```
/response send name:welcome
```
Use `/tag` in a ticket channel to set the ticket category (dropdown: Server Down, Billing, Mod Help, etc.). The bot posts: *Your ticket has been categorized as [Emoji][Tag][Emoji].*
## 3. Set Up a Ticket Panel
```
/panel #support-tickets type:both title:Need Help? description:Click below to open a ticket!
```
Use `type` to choose **thread**, **category**, or **both**. Users click the button → Fill out modal → Ticket created automatically!
## 4. Try the New Commands
### User Management
```
/add @user # Add someone to current ticket
/remove @user # Remove someone from ticket
```
### Ticket Actions
```
/transfer @staff # Transfer to another staff member
/move #category # Move to different category
/priority [level] # Set priority: posts upgraded/downgraded/normal message; email sent when set to high
/topic Important! # Set channel topic
/escalate [reason] [tier] # Escalate to tier 2 or 3 (or use Escalate button)
/deescalate # De-escalate one step
/force-close # Close without confirmation
```
### Close Confirmation
Click "Close Ticket" button → Get confirmation prompt → Confirm or cancel
## 5. Configure New Options
Edit your `.env`:
```env
# Enable auto-unclaim after 24 hours of inactivity
AUTO_UNCLAIM_ENABLED=true
AUTO_UNCLAIM_AFTER_HOURS=24
# Allow staff to claim already-claimed tickets
ALLOW_CLAIM_OVERWRITE=true
# Use threads instead of channels (future)
USE_THREADS=false
```
**Restart the bot** after changing `.env`; slash commands may need re-registration (restart the bot).
## 6. Use Variables in Tags
Create smart tags with dynamic content:
```
/response create name:closing content:Thanks {ticket.user}! Ticket #{ticket.number} is now closed. Contact us anytime at {server.name}!
```
Available variables:
- `{ticket.user}`, `{ticket.email}`, `{ticket.number}`, `{ticket.subject}`
- `{staff.name}`, `{staff.mention}`
- `{server.name}`, `{date}`, `{time}`
## 7. Priority Management
Set priorities for better organization:
```
/priority low # 🟢 Low priority
/priority normal # 🟡 Normal (default)
/priority medium # 🟠 Medium priority
/priority high # 🔴 High priority (sends email to ticket sender)
```
The bot posts: *Your ticket has been upgraded/downgraded to [Emoji][Level][Emoji].* or *Your ticket priority has returned to Normal.*
## 8. Test the Panel System
1. Create panel in a channel: `/panel #support`
2. As a user, click "Open Ticket" button
3. Fill out the modal form
4. Submit → Ticket channel created automatically!
## 9. View All Commands
```
/help
```
Shows organized list of all commands with descriptions.
## 10. Check Your Setup
Verify everything is working:
✅ All slash commands appear in Discord
✅ Can create saved responses with `/response create`; use `/tag` for ticket category
✅ Panel shows "Open Ticket" button (and optional type: thread / category / both)
✅ Clicking button shows modal form
✅ Close button shows confirmation
✅ Priority command updates ticket
`/help` command shows all features
---
## Common Issues
### Commands not showing?
- Wait up to 1 hour for Discord to sync
- Verify `DISCORD_APPLICATION_ID` in `.env`
- Restart bot
### Modal not appearing?
- Check user permissions
- Ensure bot has proper guild permissions
- Try in different channel
### Saved responses not working?
- Use `/response list` to see all tags
- Check for typos in tag name
- Autocomplete shows valid tags
---
## Next Steps
1. **Create More Tags**: Add responses for common questions
2. **Set Up Panels**: Put panels in help channels
3. **Train Staff**: Show team the new commands
4. **Enable Auto-Features**: Turn on auto-unclaim if desired
5. **Customize Messages**: Edit `.env` variables for your brand
6. **Monitor Performance**: Check logs for errors
---
## Key Features Summary
**Variables** - Dynamic message templates
🏷️ **Tags** - Saved responses system
👥 **User Management** - Add/remove users from tickets
🎫 **Panel System** - User-friendly ticket creation
📋 **Modal Forms** - Interactive ticket submission
**Priority Levels** - Organize by importance
🔄 **Transfer** - Move tickets between staff
📌 **Enhanced Claiming** - Auto-unclaim, overwrite options
**Close Confirmation** - Prevent accidental closes
📚 **Help Command** - Built-in documentation
---
## Pro Tips
💡 Use variables in welcome messages for personalization
💡 Create tags for FAQs to save time
💡 Set high priority for urgent tickets
💡 Use `/topic` to document ticket status
💡 Enable auto-unclaim to prevent stale claims
💡 Put panels in pinned messages
💡 Use `/transfer` with reasons for context
---
## Getting Help
- Read [PHASE_FEATURES.md](./PHASE_FEATURES.md) for detailed documentation
- Check logs for error messages
- Test features in a test channel first
- Use `/help` in Discord for command reference
---
**Ready to go! Enjoy your enhanced ticket system! 🚀**

View File

@@ -1,32 +0,0 @@
{
"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",
"garrys_mod": "Garry's 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"
}

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 { increment } = require('./services/patternStore');
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,347 +269,137 @@ 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();
const list = await gmail.users.messages.list({
userId: 'me',
q: 'is:unread category:primary'
});
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;
}
}
for (const msgRef of list.data.messages) {
const email = await gmail.users.messages.get({
const gmail = getGmailClient();
const list = await gmail.users.messages.list({
userId: 'me',
id: msgRef.id
q: 'is:unread category:primary'
});
if (!list.data.messages) return;
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']
}
});
continue;
}
const guild = locateGuild(client);
if (!guild) return;
const subject =
email.data.payload.headers.find(h => h.name === 'Subject')
?.value || 'New Ticket';
const rawBody = getCleanBody(email.data.payload);
for (const msgRef of list.data.messages) {
const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
const parsed = parseGmailMessage(email);
const sEmail = extractRawEmail(from).toLowerCase();
const sName =
(from.match(/^(.*?)\s*<.*>$/) || [null, from])[1]
?.replace(/"/g, '')
.trim() || 'Unknown';
if (parsed.isSelf) {
await markGmailMessageRead(gmail, msgRef);
continue;
}
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 })
.select('gmailThreadId discordThreadId status')
.lean();
const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
.select('gmailThreadId discordThreadId status')
.lean();
let ticketChan = null;
let parentCategoryIdForTicket = null;
let isReopened = false;
if (existing && existing.discordThreadId) {
ticketChan = await guild.channels
.fetch(existing.discordThreadId)
.catch(() => null);
} else if (existing && existing.status === 'closed') {
isReopened = true;
}
if (existing && existing.discordThreadId) {
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);
// 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}`,
if (ticketChan) {
// 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 ${parsed.senderEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: ['roles'] }
}
);
} else {
// Check ticket limits before creating
const limitCheck = await checkTicketLimits(sEmail);
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']
}
});
continue;
}
} else {
// Create a new ticket channel.
const limitCheck = await checkTicketLimits(parsed.senderEmail);
if (!limitCheck.ok) {
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}`);
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;
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
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(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(parsed.subject) || 'No subject'}\n\`\`\``, inline: false }
);
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;
}
const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed],
components: [buttons],
allowedMentions: { parse: ['roles'] }
});
const { createStaffThread } = require('./services/staffThread');
await createStaffThread(ticketChan, client).catch(() => {});
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
const { pinMessage } = require('./services/pinMessage');
await pinMessage(welcomeMsg, client).catch(() => {});
}
} catch (err) {
console.error('Channel create error (payload):', {
message: err.message,
code: err.code,
rawError: err.rawError
if (isReopened) {
await linkPreviousTranscripts(ticketChan, parsed.threadId, client);
}
// Email body is attacker-controlled — no mentions may fire from its content.
const truncated = parsed.firstBody.slice(0, 1900);
await enqueueSend(ticketChan, {
content: `**Message:**\n${truncated}`,
allowedMentions: { parse: [] }
});
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
continue;
}
const detectedGame = detectGame(subject, rawBody);
// 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 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 ticketInfoEmbed = new EmbedBuilder()
.setColor(CONFIG.EMBED_COLOR_INFO)
.addFields(
{ name: 'Name', value: `\`${sName}\``, inline: false },
{ name: 'Email', value: `\`${sEmail}\``, inline: false },
{ name: 'Game', value: `\`${detectedGame}\``, inline: false },
{ name: 'Subject', value: `\`${subject || 'No subject'}\``, inline: false }
);
enforceEmbedLimit([ticketInfoEmbed]);
const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed],
components: [buttons],
allowedMentions: { parse: ['roles'] }
});
const { createStaffThread } = require('./services/staffThread');
await createStaffThread(ticketChan, client).catch(() => {});
if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
const { pinMessage } = require('./services/pinMessage');
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]
});
}
}
const now = new Date();
const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
await withRetry(() => Ticket.findOneAndUpdate(
{ gmailThreadId: parsed.threadId },
{
$set: {
discordThreadId: ticketChan.id,
senderEmail: parsed.senderEmail,
subject: parsed.subject,
createdAt: now,
status: 'open',
ticketNumber: number,
priority: defaultPriority,
lastActivity: now,
parentCategoryId: parentCategoryIdForTicket
}
}
} catch (err) {
console.error('Error linking previous transcripts:', err);
}
},
{ upsert: true, new: true }
));
}
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: [] } });
// 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 },
{
$set: {
discordThreadId: ticketChan.id,
senderEmail: sEmail,
subject,
createdAt: now,
status: 'open',
ticketNumber: number,
priority: defaultPriority,
lastActivity: now,
parentCategoryId: parentCategoryIdForTicket
}
},
{ upsert: true, new: true }
));
totalProcessed++;
logGmail(subject, sEmail, number, detectedGame).catch(() => {});
increment('user_tickets', sEmail, 'today');
increment('user_tickets', sEmail, 'week');
if (detectedGame) {
increment('game_tickets', detectedGame, 'today');
increment('game_tickets', detectedGame, 'week');
}
console.log('Archiving/reading Gmail message', msgRef.id);
await markGmailMessageRead(gmail, msgRef);
}
console.log('Archiving/reading Gmail message', msgRef.id);
await gmail.users.messages.batchModify({
userId: 'me',
requestBody: {
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX']
}
});
authErrorNotified = false;
} catch (e) {
oauthSuspendIfPermanent(e, client);
console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {});
}
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++;
console.error('POLL ERROR:', e);
logError('Gmail poll', e, null, client).catch(() => {});
}
} finally {
isPolling = false;
}

View File

@@ -1,195 +0,0 @@
/**
* Account info command: look up website User by email or Discord ID,
* show ephemeral embed with option to send transcript to account info channel.
*/
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
const { CONFIG } = require('../config');
const { mongoose } = require('../db-connection');
const { logSecurity } = require('../services/debugLog');
const { enqueueSend } = require('../services/channelQueue');
const { isStaff } = require('../utils');
const User = mongoose.model('User');
const BUTTON_PREFIX = 'send_account_info_';
const MAX_CUSTOM_ID_LENGTH = 100;
function buildAccountInfoEmbed(user, requestedBy = null) {
const embed = new EmbedBuilder()
.setTitle('Account Info')
.setColor(CONFIG.EMBED_COLOR_INFO)
.setTimestamp();
embed.addFields({
name: 'Email',
value: user.email || '*not set*',
inline: true
});
embed.addFields({
name: 'Discord ID',
value: user.discordID ? `<@${user.discordID}>` : '*not set*',
inline: true
});
embed.addFields({
name: 'Customer ID',
value: user.customerId || '*not set*',
inline: true
});
const servers = user.servers || [];
const serverOrder = user.serverOrder || [];
const ordered = serverOrder.length
? serverOrder.map(id => servers.find(s => s._id && s._id.toString() === id) || servers[serverOrder.indexOf(id)]).filter(Boolean)
: servers;
if (ordered.length === 0) {
embed.addFields({
name: 'Servers',
value: '*No servers*',
inline: false
});
} else {
ordered.forEach((server, i) => {
const n = i + 1;
embed.addFields({
name: `Server ${n} Game`,
value: server.game || '*not set*',
inline: true
});
embed.addFields({
name: `Server ${n} IP`,
value: server.ip || '*not set*',
inline: true
});
embed.addFields({
name: `Server ${n} Port`,
value: server.serverPort != null ? String(server.serverPort) : '*not set*',
inline: true
});
});
}
if (requestedBy) {
embed.setFooter({ text: `Requested by ${requestedBy}` });
}
return embed;
}
async function handleAccountInfoCommand(interaction) {
const subcommand = interaction.options.getSubcommand();
let user = null;
if (subcommand === 'email') {
const email = (interaction.options.getString('email') || '').trim().toLowerCase();
if (!email) {
return interaction.reply({ content: 'Please provide an email.', ephemeral: true });
}
user = await User.findOne({ email }).lean();
} else if (subcommand === 'discord') {
const target = interaction.options.getUser('user');
if (!target) {
return interaction.reply({ content: 'Please provide a Discord user.', ephemeral: true });
}
user = await User.findOne({ discordID: target.id }).lean();
}
if (!user) {
return interaction.reply({
content: subcommand === 'email' ? 'No account found for that email.' : 'No account found for that Discord user/ID.',
ephemeral: true
});
}
const identifier = subcommand === 'email'
? interaction.options.getString('email')
: interaction.options.getUser('user')?.tag || 'unknown';
logSecurity('Account lookup', interaction.user, `lookup: ${subcommand}${identifier}`, null, 0x0099ff).catch(() => {});
const embed = buildAccountInfoEmbed(user, interaction.user.tag);
const components = [];
if (CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
const safeEmail = (user.email || '').slice(0, 50);
const safeDiscordId = (user.discordID || '').slice(0, 50);
const customId = `${BUTTON_PREFIX}discord:${safeDiscordId}`;
if (customId.length <= MAX_CUSTOM_ID_LENGTH) {
components.push(
new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId(customId)
.setLabel('Send to account info channel')
.setStyle(ButtonStyle.Secondary)
)
);
}
}
await interaction.reply({
embeds: [embed],
components,
ephemeral: true
});
}
async function handleSendAccountInfoToChannel(interaction) {
if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false;
// Dispatched directly from interactionCreate — no upstream command-level staff gate here, so enforce it.
if (!isStaff(interaction.member)) {
logSecurity('Unauthorized account-info button', interaction.user, `non-staff pressed ${interaction.customId}`, null, 0xff0000).catch(() => {});
await interaction.reply({ content: 'You do not have permission to do that.', ephemeral: true }).catch(() => {});
return true;
}
const payload = interaction.customId.slice(BUTTON_PREFIX.length);
const [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];
let user = null;
if (type === 'email') {
const email = Buffer.from(value, 'base64').toString('utf8').toLowerCase();
user = await User.findOne({ email }).lean();
} else if (type === 'discord' && value) {
user = await User.findOne({ discordID: value }).lean();
}
if (!user) {
await interaction.update({ content: 'Account no longer found.', components: [] }).catch(() =>
interaction.followUp({ content: 'Account no longer found.', ephemeral: true })
);
return true;
}
if (!CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
await interaction.update({ content: 'Account info channel is not configured.', components: [] }).catch(() =>
interaction.followUp({ content: 'Account info channel is not configured.', ephemeral: true })
);
return true;
}
const channel = await interaction.client.channels.fetch(CONFIG.ACCOUNT_INFO_CHANNEL_ID).catch(() => null);
if (!channel) {
await interaction.update({ content: 'Could not find account info channel.', components: [] }).catch(() =>
interaction.followUp({ content: 'Could not find account info channel.', ephemeral: true })
);
return true;
}
const embed = buildAccountInfoEmbed(user, `${interaction.user.tag} (from ticket)`);
await enqueueSend(channel, { embeds: [embed] });
await interaction.update({
content: 'Account info sent to account transcript channel.',
components: []
}).catch(() =>
interaction.followUp({ content: 'Account info sent to account transcript channel.', ephemeral: true })
);
return true;
}
module.exports = {
buildAccountInfoEmbed,
handleAccountInfoCommand,
handleSendAccountInfoToChannel,
BUTTON_PREFIX
};

View File

@@ -1,89 +0,0 @@
/**
* In-memory analytics and error tracking.
*/
const { logError } = require('../services/debugLog');
const analytics = {
commands: {},
buttons: {},
modals: {},
contextMenus: {},
errors: [],
startTime: Date.now()
};
function trackInteraction(type, name, userId = 'unknown') {
analytics[type][name] = (analytics[type][name] || 0) + 1;
console.log(`📊 Analytics: ${type}/${name} by ${userId}`);
}
function getTotalInteractions() {
let total = 0;
for (const type of ['commands', 'buttons', 'modals', 'contextMenus']) {
for (const key in analytics[type]) {
total += analytics[type][key];
}
}
return total;
}
function trackError(context, error, interaction = null) {
const errorEntry = {
context,
message: error.message,
stack: error.stack,
timestamp: Date.now(),
user: interaction?.user?.tag || 'system',
command: interaction?.commandName || 'N/A'
};
analytics.errors.push(errorEntry);
if (analytics.errors.length > 100) {
analytics.errors.shift();
}
console.error(`❌ Error tracked: ${context}:`, error.message);
logError(context, error, interaction);
const recentErrors = analytics.errors.filter(e =>
Date.now() - e.timestamp < 3600000
);
const errorRate = recentErrors.length / Math.max(1, getTotalInteractions());
if (errorRate > 0.05) {
console.warn(`⚠️ HIGH ERROR RATE: ${(errorRate * 100).toFixed(2)}% in last hour`);
}
}
function getAnalyticsSummary() {
const uptime = Math.floor((Date.now() - analytics.startTime) / 1000);
const totalInteractions = getTotalInteractions();
const recentErrors = analytics.errors.filter(e =>
Date.now() - e.timestamp < 3600000
);
return {
uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
totalInteractions,
commandsUsed: Object.keys(analytics.commands).length,
mostUsedCommand: Object.entries(analytics.commands)
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'None',
errorsLastHour: recentErrors.length,
errorRate: `${((recentErrors.length / Math.max(1, totalInteractions)) * 100).toFixed(2)}%`,
topCommands: Object.entries(analytics.commands)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([cmd, count]) => `${cmd}: ${count}`)
};
}
module.exports = {
analytics,
trackInteraction,
trackError,
getTotalInteractions,
getAnalyticsSummary
};

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,12 +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 { pingStaffChannel } = require('../services/staffChannel');
const { notifyStaffOfReply } = require('../services/staffNotifications');
const { logError } = require('../services/debugLog');
const Ticket = mongoose.model('Ticket');
@@ -21,48 +20,33 @@ async function handleDiscordReply(m) {
const ticket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (!ticket) return;
if (ticket.claimerId && m.author.id !== ticket.claimerId && ticket.staffChannelId) {
try {
const staffChan = await m.guild.channels.fetch(ticket.staffChannelId).catch(() => null);
if (staffChan) {
await pingStaffChannel(staffChan, ticket.claimerId, m);
}
const dmEnabled = await getNotifyDm(ticket.claimerId);
if (dmEnabled) {
const staffMember = 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
.send(
`New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})`
)
.catch(() => {});
}
}
} catch (e) {
console.error('Staff ping error:', e);
}
}
// 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(() => {}));
// Notify claiming staff if a non-staff user replied (works for both Discord and email tickets)
if (ticket.claimerId && !isStaffMember) {
const guild = m.guild;
const freshTicket = await Ticket.findOne({ discordThreadId: m.channel.id }).lean();
if (freshTicket) {
await notifyStaffOfReply(guild, freshTicket, m).catch(e => console.error('notifyStaffOfReply:', e));
// 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) {
// 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
.send(
`New customer reply in **${m.channel.name}**:\n> ${m.content.slice(0, 300)}\n[Jump to message](${jumpLink})`
)
.catch(() => {});
}
}
}
const discordUser = m.member?.displayName || m.author.username;
if (ticket.gmailThreadId.startsWith('discord-')) {
return;
}
@@ -106,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 };

820
models.js
View File

@@ -1,802 +1,10 @@
var mongoose = require('mongoose');
mongoose.model('Host', new mongoose.Schema({
hostname: String,
ip: String,
region: String,
provider: String,
memory: String,
status: String,
ipGateway: String,
memFree: Number,
cpuUsage: Number,
diskFree: Number,
lastSeen: { type: Number, default: Date.now },
lostInUse: { type: [Number], default: [] },
statsHistory: [{
timestamp: Number,
memFree: Number,
cpuUsage: Number,
diskFree: Number
}]
}));
// Update for each new game
mongoose.model('User', new mongoose.Schema({
email: String,
discordID: {type: String, default: ""},
customerId: String,
usedPaypal: {type: Boolean, default: false},
passwordHash: String,
resetPasswordToken: String,
resetPasswordExpires: Date,
sessionToken: {type: String, default: null},
indifferentBroccoli: {type: Boolean, default: false},
paymentLink: {type: String, default: null},
palpocalypseEligible: {type: Boolean, default: false},
palpocalypseClaimed: {type: Boolean, default: false},
//Admin
machineStats: [{
name: String,
memoryFree: Number,
cpuUsagePercentage: Number,
diskFree: Number
}],
//Subusers
subUserServers: [{
linuxUsername: String,
permissions: { type: Object, default: {} }
}],
subusers: [{
email: String,
inviteToken: String,
inviteExpires: Date,
}],
// Activity log
activities: [{
serverId: mongoose.Schema.Types.ObjectId,
action: String,
timestamp: { type: Number, default: Date.now }
}],
serverOrder: [String],
servers: [{
// Public server page info
tags: String,
thumbnailImageLink: String,
links: [String],
// Server settings
status: {type: String, default: "Setting Up"},
isTrial: {type: Boolean, default: false},
trialExpiry: {type: Date, default: null},
sentExpiryNotification: {type: Boolean, default: false},
sentTrialEndedNotification: {type: Boolean, default: false},
sentWelcomeFeedbackRequest: {type: Boolean, default: false},
sentUpcomingCancellationNotice : {type: Boolean, default: false},
linuxUsername: String,
linuxPassword: String, //todo: store hash
telnetPassword: String,
controlPanelPassword: String,
subscriptionId: {type:String,default:null},
subscriptionId_PayPal: {type:String,default:null},
subscriptionId_PayPalFrozen: {type:String,default:null},
subscriptionActive: {type: Boolean, default: false},
subscriptionStatus: {type: String, default: null},
subscriptionScheduledFreeze: {type: String, default: null},
subscriptionScheduledFreezeJobId: {type: String, default: null},
subscriptionScheduledCancel: {type: String, default: null},
subscriptionScheduledCancelJobId: {type: String, default: null},
ip: String,
ipGateway: {type: String, default: null},
serverPort: {type: Number, default: null},
serverPortGateway: {type: Number, default: null},
region: {type: String, default: "na-east"},
game: String,
discordAdmins: {type: [String], default: []},
// Generic game settings
serverName: String,
serverPassword: String, //todo: store hash?
gameWorld: String, // pre-Alpha 17
serverDescription: String,
serverMaxPlayerCount: Number,
worldName: String,
// StarRupture settings
sessionName: {type: String, default: "IndifferentWorld"},
saveGameInterval: {type: Number, default: 300},
saveGameName: {type: String, default: "AutoSave0.sav"},
loadSavedGame: {type: Boolean, default: true},
scheduledRestarts: {
type: [{
command: {type: String, default: "restart"},
rconCommand: String,
minute: Number,
hour: Number,
day: Number,
intervalValue: Number,
intervalUnit: String,
intervalMinute: Number
}],
default: []
},
admins: [{
steamId: String,
permissionLevel: Number
}],
backups: [{
timestamp: Number
}],
ActiveMods: [{
modId: String
}],
playersOnline: [{
name: String,
raw: {
score: Number,
time: Number
}
}],
//-----7 Day to Die-----//
serverIsPublic: String, // pre-Alpha 17
adminPassword: String, //todo: store hash?,
serverWebsiteUrl: String,
gameName: String,
gameDifficulty: Number,
gameMode: String, // don't make the GameModeSurvival or else anyone can place blocks anywhere
zombiesRun: Number,
buildCreate: String,
dayNightLength: Number,
dayLightLength: Number,
playerKillingMode: Number,
persistentPlayerProfiles: String,
playerSafeZoneLevel: Number,
playerSafeZoneHours: Number,
deathPenalty: Number,
dropOnDeath: Number,
dropOnQuit: Number,
bloodMoonEnemyCount: Number,
enemySpawnMode: String,
enemyDifficulty: Number,
blockDurabilityModifier: Number,
lootAbundance: Number,
lootRespawnDays: Number,
landClaimSize: Number,
landClaimDeadZone: Number,
landClaimExpiryTime: Number,
landClaimDecayMode: Number,
landClaimOnlineDurabilityModifier: Number,
landClaimOfflineDurabilityModifier: Number,
airDropFrequency: Number,
airDropMarker: String,
maxSpawnedZombies: Number,
maxSpawnedAnimals: Number,
eacEnabled: String,
maxUncoveredMapChunksPerPlayer: Number,
bedrollDeadZoneSize: Number,
questProgressionDailyLimit: Number,
maxChunkAge: Number,
serverAllowCrossplay: {type: String, default: "false"},
biomeProgression: {type: String, default: "true"},
stormFreq: {type: Number, default: 100},
allowSpawnNearFriend: {type: Number, default: 2},
ignoreEOSSanctions: {type: String, default: "false"},
playerCount: Number,
jarRefund: {type: Number, default: 0},
// Alpha >= 17 only
version: Number,
serverVisibility: Number,
serverReservedSlots: Number,
serverReservedSlotsPermission: Number,
serverDisabledNetworkProtocols: String,
worldGenSeed: String,
worldGenSize: Number, //todo: figure out max
telnetFailedLoginLimit: Number, // todo: figure out max or don't use
telnetFailedLoginsBlocktime: Number, // todo: figure out max or don't use
terminalWindowEnabled: Boolean, //pre Alpha 17.2 default was false
partySharedKillRange: Number,
hideCommandExecutionLog: Number,
serverLoginConfirmationText: String,
zombieFeralSense: Number,
zombieMove: Number,
zombieMoveNight: Number,
zombieFeralMove: Number,
zombieBMMove: Number,
// Alpha >= 17.2 only
bloodMoonFrequency: Number,
bloodMoonRange: Number,
bloodMoonWarning: Number,
xpMultiplier: Number,
blockDamagePlayer: Number,
blockDamageAI: Number,
blockDamageAIBM: Number,
landClaimCount: Number,
// Alpha >= 18 only
serverMaxAllowedViewDistance: Number,
serverMaxWorldTransferSpeedKiBs: Number,
bedrollExpiryTime: Number,
sevenDaysRegion: String,
language: String,
//-----Abiotic Factor-----//
//-----ARK-----//
BETA: {type: String, default: "public"},
AdminLogging: Boolean,
AllowCaveBuildingPvE: Boolean,
AllowFlyerCarryPvE: Boolean,
AllowHideDamageSourceFromLogs: Boolean,
AllowSharedConnections: Boolean,
AllowTekSuitPowersInGenesis: Boolean,
allowThirdPersonPlayer: Boolean,
alwaysNotifyPlayerJoined: Boolean,
alwaysNotifyPlayerLeft: Boolean,
AutoSavePeriodMinutes: Number,
bAllowPlatformSaddleMultiFloors: Boolean,
BanListURL: String,
bForceCanRideFliers: Boolean,
ClampResourceHarvestDamage: Boolean,
Cluster: [{
serverName: String,
gameWorld: String,
clusterId: String,
serverId: String,
serverPort: Number,
serverMaxPlayerCount: Number
}],
CrossARKAllowForeignDinoDownloads: Boolean,
Crossplay: {type: Boolean, default: false},
NoBattlEye: {type: Boolean, default: false},
ForceAllowCaveFlyers: {type: Boolean, default: false},
ShowFloatingDamageText: {type: Boolean, default: false},
CryopodNerfDamageMult: Number,
CryopodNerfDuration: Number,
CryopodNerfIncomingDamageMultPercent: Number,
CustomDynamicConfigUrl: String,
DayCycleSpeedScale: Number,
DayTimeSpeedScale: Number,
DifficultyOffset: Number,
DinoCharacterFoodDrainMultiplier: Number,
DinoCharacterHealthRecoveryMultiplier: Number,
DinoCharacterStaminaDrainMultiplier: Number,
DinoCountMultiplier: Number,
DinoDamageMultiplier: Number,
DinoResistanceMultiplier: Number,
DisableDinoDecayPvE: Boolean,
DisablePvEGamma: Boolean,
DisableStructureDecayPvE: Boolean,
DisableWeatherFog: Boolean,
EnableCryopodNerf: Boolean,
EnableCryoSicknessPVE: Boolean,
EnablePvPGamma: Boolean,
GameIniSettings: [{
text: String
}],
globalVoiceChat: Boolean,
HarvestAmountMultiplier: Number,
HarvestHealthMultiplier: Number,
ItemStackSizeMultiplier: Number,
MaxGateFrameOnSaddles: Number,
MaxPlatformSaddleStructureLimit: Number,
MaxPlayers: Number,
MaxStructuresInRange: Number,
MaxTamedDinos: Number,
MaxTributeDinos: Number,
MaxTributeItems: Number,
NightTimeSpeedScale: Number,
noTributeDownloads: Boolean,
PerPlatformMaxStructuresMultiplier: Number,
PlatformSaddleBuildAreaBoundsMultiplier: Number,
PlayerCharacterFoodDrainMultiplier: Number,
PlayerCharacterHealthRecoveryMultiplier: Number,
PlayerCharacterStaminaDrainMultiplier: Number,
PlayerCharacterWaterDrainMultiplier: Number,
PlayerDamageMultiplier: Number,
PlayerResistanceMultiplier: Number,
proximityChat: Boolean,
PvEDinoDecayPeriodMultiplier: Number,
PvEStructureDecayDestructionPeriod: Number,
PvEStructureDecayPeriodMultiplier: Number,
PvPStructureDecay: Boolean,
RandomSupplyCratePoints: Boolean,
ResourcesRespawnPeriodMultiplier: Number,
ServerAdminPassword: String,
serverForceNoHud: Boolean,
serverHardcore: Boolean,
serverPVE: Boolean,
ShowMapPlayerLocation: Boolean,
SpectatorPassword: String,
StructureDamageMultiplier: Number,
StructureResistanceMultiplier: Number,
TamingSpeedMultiplier: Number,
TheMaxStructuresInRange: Number,
TribeNameChangeCooldown: Number,
TributeCharacterExpirationSeconds: Number,
TributeDinoExpirationSeconds: Number,
TributeItemExpirationSeconds: Number,
XPMultiplier: Number,
gameIni: String,
//-----Conan Exiles-----//
modList: String,
//-----Core Keeper-----//
gameID: String,
worldSeed: {type: Number, default: 0},
worldIndex: {type: Number, default: 0},
worldMode: {type: Number, default: 0},
season: {type: Number, default: -1},
corekeeperMods: {type: String, default: ""},
//-----Counter Strike 2 (CS2)-----//
//-----DayZ-----//
enableWhitelist: { type: Boolean, default: false },
disable3rdPerson: { type: Boolean, default: false },
disableCrosshair: { type: Boolean, default: false },
disablePersonalLight: { type: Boolean, default: false },
disableVoicechat: { type: Boolean, default: false },
modList: {type: String, default: ""},
//-----ECO-----//
//-----Enshrouded-----//
//-----Factorio-----//
spaceAgeEnabled: {type: Boolean, default: true},
autoUpdateMods: {type: Boolean, default: false},
visibilityPublic: {type: Boolean, default: true},
factorioUsername: {type: String, default: ""},
factorioPassword: {type: String, default: ""},
factorioToken: {type: String, default: ""},
requireUserVerification: {type: Boolean, default: true},
allowCommands: {type: String, default: "admins-only"},
afkAutokickInterval: {type: Number, default: 0},
autoPause: {type: Boolean, default: true},
autoPauseWhenPlayersConnect: {type: Boolean, default: false},
onlyAdminsCanPause: {type: Boolean, default: true},
//-----FiveM-----//
licenseKey: {type: String, default: ""},
locale: String,
//-----The Front-----//
extraArgs: String,
//-----Garry's Mod-----//
workshopCollection: String,
serverCheats: Boolean,
customParameters: String,
GLST: String,
//-----Hytale-----//
viewDistance: {type: Number, default: 12},
MaxViewRadius: {type: Number, default: 32},
serverMOTD: {type: String, default: ""},
defaultWorld: {type: String, default: "default"},
selectedWorld: {type: String, default: "default"},
IsPvpEnabled: {type: Boolean, default: false},
IsFallDamageEnabled: {type: Boolean, default: true},
IsGameTimePaused: {type: Boolean, default: false},
IsSpawningNPC: {type: Boolean, default: true},
IsSpawnMarkersEnabled: {type: Boolean, default: true},
IsAllNPCFrozen: {type: Boolean, default: false},
IsCompassUpdating: {type: Boolean, default: true},
IsObjectiveMarkersEnabled: {type: Boolean, default: true},
itemsLossMode: {type: String, default: "Configured"},
itemsAmountLossPercentage: {type: Number, default: 10},
itemsDurabilityLossPercentage: {type: Number, default: 10},
gameMode: {type: String, default: "Adventure"},
hytaleOAuthUrl: String,
hytaleAuthLinkClicked: {type: Boolean, default: false},
//-----Palworld-----//
AutoResetGuildTimeNoOnlinePlayers: {type: Number, default: 72},
bActiveUNKO: {type: Boolean, default: false},
BanListURL: {type: String, default: "https://api.palworldgame.com/api/banlist.txt"},
BaseCampMaxNum: {type: Number, default: 128},
BaseCampWorkerMaxNum: {type: Number, default: 15},
bAutoResetGuildNoOnlinePlayers: {type: Boolean, default: false},
bCanPickupOtherGuildDeathPenaltyDrop: {type: Boolean, default: false},
bEnableAimAssistKeyboard: {type: Boolean, default: false},
bEnableAimAssistPad: {type: Boolean, default: true},
bEnableDefenseOtherGuildPlayer: {type: Boolean, default: false},
bEnableFastTravel: {type: Boolean, default: true},
bEnableFriendlyFire: {type: Boolean, default: false},
bEnableInvaderEnemy: {type: Boolean, default: true},
bEnableNonLoginPenalty: {type: Boolean, default: true},
bEnablePlayerToPlayerDamage: {type: Boolean, default: false},
bExistPlayerAfterLogout: {type: Boolean, default: false},
bIsMultiplay: {type: Boolean, default: false},
bIsPvP: {type: Boolean, default: false},
bIsStartLocationSelectByMap: {type: Boolean, default: true},
BuildObjectDamageRate: {type: Number, default: 1},
BuildObjectDeteriorationDamageRate: {type: Number, default: 1},
bUseAuth: {type: Boolean, default: true},
CollectionDropRate: {type: Number, default: 1},
CollectionObjectHpRate: {type: Number, default: 1},
CollectionObjectRespawnSpeedRate: {type: Number, default: 1},
CoopPlayerMaxNum: {type: Number, default: 4},
DayTimeSpeedRate: {type: Number, default: 1},
DeathPenalty: {type: String, default: "All"},
Difficulty: {type: String, default: "None"},
DropItemAliveMaxHours: {type: Number, default: 1},
DropItemMaxNum: {type: Number, default: 3000},
DropItemMaxNum_UNKO: {type: Number, default: 100},
EnemyDropItemRate: {type: Number, default: 1},
ExpRate: {type: Number, default: 1},
GuildPlayerMaxNum: {type: Number, default: 20},
NightTimeSpeedRate: {type: Number, default: 1},
PalAutoHPRegeneRate: {type: Number, default: 1},
PalAutoHpRegeneRateInSleep: {type: Number, default: 1},
PalCaptureRate: {type: Number, default: 1},
PalDamageRateAttack: {type: Number, default: 1},
PalDamageRateDefense: {type: Number, default: 1},
PalEggDefaultHatchingTime: {type: Number, default: 72},
PalSpawnNumRate: {type: Number, default: 1},
PalStaminaDecreaceRate: {type: Number, default: 1},
PalStomachDecreaceRate: {type: Number, default: 1},
PlayerAutoHPRegeneRate: {type: Number, default: 1},
PlayerAutoHpRegeneRateInSleep: {type: Number, default: 1},
PlayerDamageRateAttack: {type: Number, default: 1},
PlayerDamageRateDefense: {type: Number, default: 1},
PlayerStaminaDecreaceRate: {type: Number, default: 1},
PlayerStomachDecreaceRate: {type: Number, default: 1},
palRegion: {type: String, default: ""},
WorkSpeedRate: {type: Number, default: 1},
Community: {type: Boolean, default: false},
BaseCampMaxNumInGuild : {type: Number, default: 3},
ConnectPlatform: {type: String, default: "Steam"},
SupplyDropSpan : {type: Number, default: 180},
palworldVersion: {type: String, default: "Latest"},
RandomizerType: {type: String, default: "None"},
RandomizerSeed: {type: String, default: ""},
ChatPostLimitPerMinute: {type: Number, default: 10},
EnablePredatorBossPal: {type: Boolean, default: true},
BuildObjectHpRate: {type: Number, default: 1},
Hardcore: {type: Boolean, default: false},
CharacterRecreateInHardcore: {type: Boolean, default: false},
PalLost: {type: Boolean, default: false},
BuildAreaLimit: {type: Boolean, default: false},
ItemWeightRate: {type: Number, default: 1},
MaxBuildingLimitNum: {type: Number, default: 0},
CrossplayPlatforms: {type: String, default: "(Steam,Xbox,PS5,Mac)"},
AllowGlobalPalboxExport: {type: Boolean, default: true},
AllowGlobalPalboxImport: {type: Boolean, default: false},
randomPalLevels: {type: Boolean, default: false},
equipmentDurabilityDamageRate: {type: Number, default: 1},
itemContainerForceMarkDirtyInterval: {type: Number, default: 1},
itemCorruptionMultiplier: {type: Number, default: 1},
//-----Project Zomboid-----//
autoRestartEnabled: {type: Boolean, default: false},
build42Unstable: {type: Boolean, default: false},
PZVersion: {type: String, default: "41.78.16"},
//-----Rust-----//
mapSize: Number,
maxMapSize: Number,
mapSeed: Number,
oxideEnabled: Boolean,
//-----Valheim-----//
valheimPlusEnabled: Boolean,
valheimPlusFork: {type: String, default: "valheimPlus"},
//-----Satisfactory-----//
serverVersion: {type: String, default: "public"},
satisfactoryAdminPassword: {type: String, default: ''},
satisfactoryHealth: {type: String, default: ''},
satisfactoryActiveSession: {type: String, default: ''},
satisfactoryTechTier: {type: Number, default: 0},
satisfactoryTickRate: {type: Number, default: 0},
satisfactoryGameDuration: {type: Number, default: 0},
satisfactoryActiveSchematic: {type: String, default: ''},
satisfactoryIsGamePaused: {type: Boolean, default: false},
//-----Sons of the Forest-----//
//-----Soulmask-----//
pvMode: {type: String, default: "pvp"},
//-----Terraria-----//
//----Already made/Non-config.json----//
//gameDifficulty: Number - 7days
//WorldGenSize: Number - 7days
MaxSlots: Number, //uses serverMaxPlayerCount
MOTD: String,
secure: Number,
//----Booleans----//
UseServerName: Boolean,
DebugLogs: Boolean,
DisableLoginBeforeJoin: Boolean,
IgnoreChestStacksOnLoad: Boolean,
Autosave: Boolean,
AnnounceSave: Boolean,
SaveWorldOnCrash: Boolean,
SaveWorldOnLastPlayerExit: Boolean,
InfiniteInvasion: Boolean,
SpawnProtection: Boolean,
RangeChecks: Boolean,
HardcoreOnly: Boolean,
MediumCoreOnly: Boolean,
DisableBuild: Boolean,
DisableHardmode: Boolean,
DisableDungeonGuardian: Boolean,
DisableClownBombs: Boolean,
DisableSnowBalls: Boolean,
DisableTombstones: Boolean,
DisableInvisPvP: Boolean,
RegionProtectChests: Boolean,
RegionProtectGemLocks: Boolean,
IgnoreProjUpdate: Boolean,
IgnoreProjKill: Boolean,
AllowCutTilesAndBreakables: Boolean,
AllowIce: Boolean,
AllowCrimsonCreep: Boolean,
AllowCorruptionCreep: Boolean,
AllowHallowCreep: Boolean,
PreventBannedItemSpawn: Boolean,
PreventDeadModification: Boolean,
PreventInvalidPlaceStyle: Boolean,
ForceXmas: Boolean,
ForceHalloween: Boolean,
AllowAllowedGroupsToSpawnBannedItems: Boolean,
AnonymousBossInvasions: Boolean,
RememberLeavePos: Boolean,
KickOnMediumcoreDeath: Boolean,
BanOnMediumCoreDeath: Boolean,
KickOnHardcoreDeath: Boolean,
BanOnHardcoreDeath: Boolean,
EnableWhitelist: Boolean,
EnableIPBans: Boolean,
EnableUUIDBans: Boolean,
EnableBanOnUsernames: Boolean,
KickProxyUsers: Boolean,
RequireLogin: Boolean,
AllowLoginAnyUsername: Boolean,
AllowRegisterAnyUsername: Boolean,
DisableUUIDLogin: Boolean,
KickEmptyUUID: Boolean,
KickOnTilePaintThresholdBroken: Boolean,
KickOnTileLiquidThresholdBroken: Boolean,
KickOnTileKillThresholdBroken: Boolean,
KickOnTilePlaceThresholdBroken: Boolean,
KickOnDamageThresholdBroken: Boolean,
KickOnProjectileThresholdBroken: Boolean,
KickOnHealOtherThresholdBroken: Boolean,
ProjIgnoreShrapnel: Boolean,
DisableSpewLogs: Boolean,
DisableSecondUpdateLogs: Boolean,
EnableGeoIP: Boolean,
DisplayIPToAdmins: Boolean,
EnableChatAboveHeads: Boolean,
//----Numbers----//
ReservedSlots: Number,
InvasionMultiplier: Number,
DefaultSpawnRate: Number,
DefaultMaximumSpawns: Number,
SpawnProtectionRadius: Number,
MaxRangeForDisabled: Number,
StatueSpawn200: Number,
StatueSpawn600: Number,
StatueSpawnWorld: Number,
RespawnSeconds: Number,
RespawnBossSeconds: Number,
MaxHP: Number,
MaxMP: Number,
BombExplosionRadius: Number,
MaximumLoginAttempts: Number,
MinimumPasswordLength: Number,
BCryptWorkFactor: Number,
TilePaintThreshold: Number,
TileKillThreshold: Number,
TilePlaceThreshold: Number,
TileLiquidThreshold: Number,
MaxDamage: Number,
MaxProjDamage: Number,
ProjectileThreshold: Number,
HealOtherThreshold: Number,
//----Strings----//
PvPMode: String,
ForceTime: String,
DefaultRegistrationGroupName: String,
DefaultGuestGroupName: String,
MediumcoreKickReason: String,
MediumcoreBanReason: String,
HardcoreKickReason: String,
HardcoreBanReason: String,
WhitelistKickReason: String,
ServerFullReason: String,
ServerFullNoReservedReason: String,
HashAlgorithm: String,
CommandSpecifier: String,
CommandSilentSpecifier: String,
SuperAdminChatPrefix: String,
SuperAdminChatSuffix: String,
ChatFormat: String,
ChatAboveHeadsFormat: String,
//-----Minecraft-----//
saveName: String,
enableCommandBlock: Boolean,
allowFlight: Boolean,
iconLink: String,
resourcePackLink: String,
resourcePackLinkSHA1: String,
requireResourcePack: Boolean,
resourcePackPrompt: String,
enforceWhitelist: Boolean,
maxBuildHeight: Number,
allowNether: Boolean,
generateStructures: Boolean,
spawnAnimals: Boolean,
spawnNPCS: Boolean,
spawnMonsters: Boolean,
forceGamemode: Boolean,
enableHardcore: Boolean,
enablePvP: Boolean,
playerIdleTimeout: Number,
serverMaxAllowedViewDistance: Number,
levelType: String,
generatorSettings: String,
enableRcon: Boolean,
rconPassword: String,
broadcastRconToOps: Boolean,
broadcastConsoleToOps: Boolean,
opPermissionLevel: Number,
functionPermissionLevel: Number,
serverType: {type: String, default: "VANILLA"},
serverTypeVersion: {type: String, default: ""},
gameDifficultyString: String,
maxPlayers: Number,
modpackurl: String,
modpackName: String,
modpackVersion: String,
mcVersion: {type: String, default: "LATEST"},
javaVersion: {type: String, default: "latest"},
maxTickTime: Number,
spawnProtectionRadius: Number,
selectedMods: [{
// For Minecraft
name: String,
slug: String,
// For Project Zomboid
title: String,
workshopId: String,
modId: String,
mapFolder: String,
}],
//-----VRising-----//
vrisingBepInExEnabled: Boolean,
//-----Icarus-----//
shutdownIfNotJoinedFor: Number,
shutdownIfEmptyFor: Number,
//-----Vintage Story-----//
serverLanguage: String,
serverWelcomeMessage: String,
whitelistMode: Number,
allowPvp: Boolean,
verifyPlayerAuth: Boolean,
allowFireSpread: Boolean,
allowFallingBlocks: Boolean,
passTimeWhenEmpty: Boolean,
clientConnectionTimeout: Number,
maxChunkRadius: Number,
chatRateLimit: Number,
maxOwnedGroupChannelsPerUser: Number,
seed: String,
allowCreativeMode: Boolean,
playStyle: String,
worldType: String,
mapSizeX: Number,
mapSizeY: Number,
mapSizeZ: Number,
gameMode: String,
startingClimate: String,
spawnRadius: Number,
graceTimer: Number,
deathPunishment: String,
droppedItemsTimer: Number,
seasons: String,
playerlives: Number,
lungCapacity: Number,
daysPerMonth: Number,
harshWinters: Boolean,
blockGravity: String,
caveIns: String,
allowUndergroundFarming: Boolean,
noLiquidSourceTransport: Boolean,
bodyTemperatureResistance: Number,
creatureHostility: String,
creatureStrength: Number,
creatureSwimSpeed: Number,
playerHealthPoints: Number,
playerHungerSpeed: Number,
playerHealthRegenSpeed: Number,
playerMoveSpeed: Number,
foodSpoilSpeed: Number,
saplingGrowthRate: Number,
toolDurability: Number,
toolMiningSpeed: Number,
propickNodeSearchRadius: Number,
microblockChiseling: String,
allowCoordinateHud: Boolean,
allowMap: Boolean,
colorAccurateWorldmap: Boolean,
loreContent: Boolean,
clutterObtainable: String,
lightningFires: Boolean,
allowTimeswitch: Boolean,
temporalStability: Boolean,
temporalStorms: String,
tempstormDurationMul: Number,
temporalRifts: String,
temporalGearRespawnUses: Number,
temporalStormSleeping: Number,
worldClimate: String,
landcover: Number,
oceanscale: Number,
upheavelCommonness: Number,
geologicActivity: Number,
landformScale: Number,
worldWidth: Number,
worldLength: Number,
worldEdge: String,
polarEquatorDistance: Number,
globalTemperature: Number,
globalPrecipitation: Number,
globalForestation: Number,
globalDepositSpawnRate: Number,
surfaceCopperDeposits: Number,
surfaceTinDeposits: Number,
snowAccum: Boolean,
allowLandClaiming: Boolean,
classExclusiveRecipes: Boolean,
auctionHouse: Boolean,
vsVersion: String
}]
}));
mongoose.model('DashboardMetrics', new mongoose.Schema({
timestamp: { type: Date, default: Date.now, expires: 31536000 },
activeUsers: Number,
workerId: String
}));
mongoose.model('ErrorLog', new mongoose.Schema({
timestamp: { type: Date, default: Date.now, expires: 2592000 }, // 30 days
statusCode: Number,
message: String,
stack: String,
url: String,
method: String,
userId: String,
userEmail: String,
authenticated: Boolean,
sessionValid: Boolean
}));
// ===== Broccolini Bot Models =====
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 },
@@ -806,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 });
@@ -844,19 +47,6 @@ mongoose.model('Tag', new mongoose.Schema({
useCount: { type: Number, default: 0 }
}));
mongoose.model('CloseRequest', new mongoose.Schema({
ticketId: { type: String, required: true, unique: true },
requestedBy: { type: String, required: true },
reason: String,
createdAt: { type: Date, required: true }
}));
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 },
@@ -864,14 +54,6 @@ mongoose.model('StaffSettings', new mongoose.Schema({
updatedAt: { type: Date, default: Date.now }
}));
mongoose.model('StaffNotification', new mongoose.Schema({
userId: { type: String, required: true, unique: true },
guildId: String,
channelId: String,
cooldownHours: { type: Number, default: 1 },
updatedAt: { type: Date, default: Date.now }
}));
mongoose.model('StaffSignature', 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,11 +17,8 @@
"main": "broccolini-discord.js",
"scripts": {
"start": "node broccolini-discord.js",
"start:test": "ENV_FILE=.env.test node broccolini-discord.js",
"start:1p": "op run --env-file=.env -- node broccolini-discord.js",
"start:test:1p": "op run --env-file=.env.test -- node broccolini-discord.js",
"test-mongodb": "node scripts/test-mongodb.js",
"test-mongodb:test": "ENV_FILE=.env.test node scripts/test-mongodb.js"
"test": "vitest run",
"test-mongodb": "node scripts/test-mongodb.js"
},
"keywords": [],
"author": "",

View File

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

View File

@@ -4,15 +4,7 @@ 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 { REGISTRY: NOTIFICATION_REGISTRY } = require('../services/notificationRegistry');
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
const {
getAllState: getNotificationState,
setKeyEnabled,
setCategoryEnabled,
setMasterEnabled
} = require('../services/notificationEnabled');
const router = express.Router();
@@ -27,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
@@ -61,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;
@@ -76,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/bosscordClient').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);
@@ -124,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') {
@@ -169,67 +167,6 @@ router.get('/restart/status', (req, res) => {
res.json({ scheduledRestart: !!scheduledRestart });
});
// GET /notifications/alerts — canonical bot-side notification alert catalog
router.get('/notifications/alerts', (req, res) => {
res.json(NOTIFICATION_REGISTRY);
});
// GET /notifications/state — Phase 9: master flag + per-key enable map
router.get('/notifications/state', (req, res) => {
res.json(getNotificationState());
});
// POST /notifications/toggle — Phase 9: mutate one of {master, category, key}
//
// Body shapes (exactly one of these must be used):
// { master: true, enabled: <bool> }
// { category: <str>, enabled: <bool> }
// { key: <str>, enabled: <bool> }
//
// Mutates CONFIG in memory via notificationEnabled, then persists through
// applyConfigUpdates so the value passes schema validation and ends up in .env.
router.post('/notifications/toggle', express.json(), async (req, res) => {
const body = req.body;
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return res.status(400).json({ error: 'Invalid body' });
}
if (typeof body.enabled !== 'boolean') {
return res.status(400).json({ error: '`enabled` must be boolean' });
}
const hasMaster = Object.prototype.hasOwnProperty.call(body, 'master');
const hasCategory = Object.prototype.hasOwnProperty.call(body, 'category');
const hasKey = Object.prototype.hasOwnProperty.call(body, 'key');
const specifiedCount = Number(hasMaster) + Number(hasCategory) + Number(hasKey);
if (specifiedCount !== 1) {
return res.status(400).json({ error: 'Specify exactly one of: master, category, key' });
}
let updates;
if (hasMaster) {
setMasterEnabled(body.enabled);
updates = { NOTIFICATIONS_MASTER_ENABLED: body.enabled };
} else if (hasCategory) {
if (typeof body.category !== 'string' || !Object.prototype.hasOwnProperty.call(NOTIFICATION_REGISTRY, body.category)) {
return res.status(400).json({ error: 'Unknown category' });
}
const newJson = setCategoryEnabled(body.category, body.enabled);
updates = { NOTIFICATION_ENABLED_JSON: newJson };
} else {
if (typeof body.key !== 'string' || !body.key) {
return res.status(400).json({ error: '`key` must be a non-empty string' });
}
const newJson = setKeyEnabled(body.key, body.enabled);
updates = { NOTIFICATION_ENABLED_JSON: newJson };
}
const result = applyConfigUpdates(updates);
if (result.errors.length > 0) {
return res.status(500).json({ error: 'Persistence failed', details: result.errors });
}
res.json({ state: getNotificationState() });
});
// POST /gmail/reload — hot-swap Gmail OAuth creds after weekly reauth without
// restarting the process. Reads REFRESH_TOKEN from .env via configPersistence,
// probes Google with users.getProfile, and on success clears pollSuspended and
@@ -246,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,8 @@
/**
* Test MongoDB connection using the native driver.
* Uses MONGODB_URI from .env (or ENV_FILE when set). Run: npm run test-mongodb
* Uses MONGODB_URI from .env. Run: npm run test-mongodb
*/
const path = require('path');
const dotenv = require('dotenv');
const envPath = process.env.ENV_FILE ? path.resolve(process.cwd(), process.env.ENV_FILE) : undefined;
dotenv.config({ path: envPath });
require('dotenv').config();
const { MongoClient, ServerApiVersion } = require('mongodb');

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

@@ -1,98 +0,0 @@
/**
* Chat monitoring — tracks unresponded messages in configured channels
* and alerts staff when thresholds are crossed.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { shouldFireCooldownEscalating, clearEscalating } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
const CHAT_ALERT_KEYS = ['chat_messages', 'chat_time'];
assertKeysRegistered('chatAlertChecker', CHAT_ALERT_KEYS);
// channelId → { lastStaffMessageAt, unrespondedCount, lastAlertAt }
const chatState = new Map();
const chatMessageThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_messages || [])
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n > 0);
const chatTimeThresholdsMs = (CONFIG.NOTIFICATION_THRESHOLDS?.chat_time || [])
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n > 0);
function initChatMonitoring(client) {
for (const channelId of CONFIG.CHAT_ALERT_CHANNEL_IDS) {
chatState.set(channelId, {
lastStaffMessageAt: new Date(),
unrespondedCount: 0,
lastAlertAt: null
});
}
}
function isStaff(member) {
if (!member?.roles?.cache) return false;
if (CONFIG.ROLE_ID_TO_PING && member.roles.cache.has(CONFIG.ROLE_ID_TO_PING)) return true;
const additional = CONFIG.ADDITIONAL_STAFF_ROLES || [];
return additional.some(roleId => member.roles.cache.has(roleId));
}
async function handleChatMessage(msg, client) {
if (msg.author.bot) return;
if (!chatState.has(msg.channel.id)) return;
const state = chatState.get(msg.channel.id);
if (isStaff(msg.member)) {
state.lastStaffMessageAt = new Date();
state.unrespondedCount = 0;
clearEscalating(`chat:messages:${msg.channel.id}`);
clearEscalating(`chat:time:${msg.channel.id}`);
} else {
state.unrespondedCount++;
}
}
async function runChatAlertChecks(client) {
const alertChannelId = CONFIG.ALL_STAFF_CHAT_ALERT_CHANNEL_ID;
if (!alertChannelId || !client) return;
for (const [channelId, state] of chatState) {
// Message count threshold
if (isEnabled('chat_messages') && state.unrespondedCount >= CONFIG.CHAT_ALERT_MESSAGE_COUNT) {
const cooldownKey = `chat:messages:${channelId}`;
if (shouldFireCooldownEscalating(cooldownKey, chatMessageThresholdsMs) !== null) {
const embed = new EmbedBuilder()
.setTitle('Chat needs attention')
.setDescription(`<#${channelId}> has ${state.unrespondedCount} unresponded messages.`)
.setColor(0xFF8800)
.setTimestamp();
try {
const alertChan = await client.channels.fetch(alertChannelId);
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] });
} catch (_) {}
}
}
// Time threshold
const hoursSinceStaff = (Date.now() - state.lastStaffMessageAt.getTime()) / 3600000;
if (isEnabled('chat_time') && hoursSinceStaff >= CONFIG.CHAT_ALERT_HOURS_WITHOUT_RESPONSE && state.unrespondedCount > 0) {
const cooldownKey = `chat:time:${channelId}`;
if (shouldFireCooldownEscalating(cooldownKey, chatTimeThresholdsMs) !== null) {
const embed = new EmbedBuilder()
.setTitle('Chat without staff response')
.setDescription(`<#${channelId}> has had no staff response for ${Math.floor(hoursSinceStaff)} hour(s) with ${state.unrespondedCount} pending message(s).`)
.setColor(0xFF8800)
.setTimestamp();
try {
const alertChan = await client.channels.fetch(alertChannelId);
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
if (alertChan) await enqueueSend(alertChan, { content, embeds: [embed] });
} catch (_) {}
}
}
}
}
module.exports = { initChatMonitoring, handleChatMessage, runChatAlertChecks };

View File

@@ -3,9 +3,7 @@ const path = require('path');
const { CONFIG } = require('../config');
const { getValidator } = require('./configSchema');
const ENV_PATH = process.env.ENV_FILE
? path.resolve(process.env.ENV_FILE)
: path.resolve(process.cwd(), '.env');
const ENV_PATH = path.resolve(process.cwd(), '.env');
/**
* Serialize a runtime value for .env storage.
@@ -152,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,65 +19,37 @@
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',
// Roles and staff
'ROLE_ID_TO_PING', 'ROLE_TO_PING_ID', 'ADDITIONAL_STAFF_ROLES', 'BLACKLISTED_ROLES',
'STAFF_IDS', 'ADMIN_ID', 'STAFF_EMOJIS', 'CLAIMER_EMOJI_FALLBACK',
'ADMIN_ID',
// Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_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',
'ALL_STAFF_CHANNEL_ID', 'ALL_STAFF_CHAT_ALERT_CHANNEL_ID',
'STAFF_NOTIFICATION_CATEGORY_ID',
// Pattern channel IDs
'USER_PATTERNS_CHANNEL_ID', 'GAME_PATTERNS_CHANNEL_ID', 'TAG_PATTERNS_CHANNEL_ID',
'ESCALATION_PATTERNS_CHANNEL_ID', 'STAFF_PATTERNS_CHANNEL_ID', 'COMBINED_PATTERNS_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
'LOGO_URL', 'SUPPORT_NAME', 'EMAIL_SIGNATURE', 'GAME_LIST',
// Toggles
'AUTO_CLOSE_ENABLED', 'AUTO_CLOSE_AFTER_HOURS', 'AUTO_UNCLAIM_ENABLED', 'AUTO_UNCLAIM_AFTER_HOURS',
'CLAIM_TIMEOUT_ENABLED', 'CLAIM_TIMEOUT_HOURS', 'ALLOW_CLAIM_OVERWRITE',
'ALLOW_CLAIM_OVERWRITE',
'REMINDER_ENABLED', 'REMINDER_AFTER_HOURS', 'PRIORITY_ENABLED', 'DEFAULT_PRIORITY',
'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',
'STAFF_DND_COUNTS_AS_AVAILABLE',
// 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',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI',
// Pattern thresholds
'PATTERN_USER_TICKET_THRESHOLD', 'PATTERN_GAME_TICKET_THRESHOLD',
'PATTERN_STAFF_STALE_PING_THRESHOLD', 'PATTERN_ESCALATION_THRESHOLD',
'PATTERN_RAPID_CLOSE_SECONDS', 'PATTERN_UNCLAIMED_HOURS', 'PATTERN_CHECK_INTERVAL_MINUTES',
// Surge settings
'SURGE_ROLE_ID', 'SURGE_TICKET_COUNT', 'SURGE_TICKET_WINDOW_MINUTES',
'SURGE_GAME_TICKET_COUNT', 'SURGE_GAME_TICKET_WINDOW_MINUTES',
'SURGE_STALE_COUNT', 'SURGE_STALE_HOURS',
'SURGE_NEEDS_RESPONSE_COUNT', 'SURGE_NEEDS_RESPONSE_HOURS',
'SURGE_UNCLAIMED_COUNT', 'SURGE_UNCLAIMED_MINUTES', 'SURGE_TIER3_UNCLAIMED_MINUTES',
'SURGE_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_COOLDOWN_MINUTES', 'SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD',
// Chat alerts
'CHAT_ALERT_CHANNEL_IDS', 'CHAT_ALERT_MESSAGE_COUNT',
'CHAT_ALERT_HOURS_WITHOUT_RESPONSE', 'CHAT_ALERT_COOLDOWN_MINUTES',
// Notification thresholds
'NOTIFICATION_THRESHOLDS_JSON', 'UNCLAIMED_REMINDER_THRESHOLDS',
// Notification enable state (Phase 9)
'NOTIFICATION_ENABLED_JSON', 'NOTIFICATIONS_MASTER_ENABLED'
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
]);
// ---------- Regex primitives ----------
@@ -207,13 +179,9 @@ const VALIDATORS = {
function inferType(key) {
// 1. Explicit overrides
if (key === 'NOTIFICATION_THRESHOLDS_JSON') return 'json';
if (key === 'NOTIFICATION_ENABLED_JSON') return 'json';
if (key === 'NOTIFICATIONS_MASTER_ENABLED') return 'boolean';
if (key === 'LOGO_URL') return 'url';
if (/_EMAIL$/.test(key)) return 'email';
if (key.includes('COLOR')) return 'hex_color';
if (/_EMOJIS$/.test(key)) return 'string_or_json';
// ROLE_ID_TO_PING has _ID mid-key — standard _ID$ pattern misses it.
if (key === 'ROLE_ID_TO_PING') return 'discord_id';
@@ -231,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 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 (_) {
return { subject: null, msgId: null };
}
}
// 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 htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()}
</div>`;
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: ${recipient}`,
`Subject: ${encodedSubject}`,
msgId && `In-Reply-To: ${msgId}`,
msgId && `References: ${msgId}`,
'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"`
].filter(Boolean);
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(/=+$/, '');
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.`;
// 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);
}
} catch (_) {
/* use ticket.subject and no In-Reply-To if thread fetch fails */
}
const finalSubject = sanitizeHeaderValue(`${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(
finalSubject
).toString('base64')}?=`;
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 || CONFIG.EMAIL_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,
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 || CONFIG.EMAIL_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

@@ -1,102 +0,0 @@
/**
* Canonical enable/disable state accessor for per-alert notifications.
*
* State lives in two CONFIG keys:
* - NOTIFICATIONS_MASTER_ENABLED (boolean) — global kill switch
* - NOTIFICATION_ENABLED_JSON (JSON string → flat { [key]: boolean })
*
* Defaults: master off, every key off. Unknown keys in the JSON are ignored
* on read (registry is the source of truth); keys missing from the JSON are
* treated as false. Master off short-circuits every read — isEnabled never
* returns true when master is off, so checkers bail without logs or metrics.
*
* Setters mutate CONFIG in memory and return the new value so the caller can
* persist it via configPersistence.applyConfigUpdates. .env writes happen
* there so schema validation and partial-success semantics stay consistent.
*/
'use strict';
const { CONFIG } = require('../config');
const { REGISTRY } = require('./notificationRegistry');
function parseState() {
const raw = CONFIG.NOTIFICATION_ENABLED_JSON;
if (raw === undefined || raw === null || raw === '') return {};
if (typeof raw === 'object' && !Array.isArray(raw)) return raw;
try {
const parsed = JSON.parse(String(raw));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch (_) {}
return {};
}
function isMasterOn() {
const v = CONFIG.NOTIFICATIONS_MASTER_ENABLED;
return v === true || v === 'true';
}
function isEnabled(alertKey) {
if (!isMasterOn()) return false;
const state = parseState();
return state[alertKey] === true;
}
function isCategoryEnabled(category) {
if (!isMasterOn()) return false;
const entries = REGISTRY[category];
if (!Array.isArray(entries) || entries.length === 0) return false;
const state = parseState();
return entries.every(e => state[e.key] === true);
}
function getAllState() {
const state = parseState();
const perKey = {};
for (const entries of Object.values(REGISTRY)) {
if (!Array.isArray(entries)) continue;
for (const e of entries) {
perKey[e.key] = state[e.key] === true;
}
}
return { master: isMasterOn(), perKey };
}
function serialize(state) {
const ordered = {};
Object.keys(state).sort().forEach(k => { ordered[k] = state[k] === true; });
return JSON.stringify(ordered);
}
function setKeyEnabled(key, enabled) {
const state = parseState();
state[String(key)] = enabled === true;
const json = serialize(state);
CONFIG.NOTIFICATION_ENABLED_JSON = json;
return json;
}
function setCategoryEnabled(category, enabled) {
const state = parseState();
const entries = REGISTRY[category];
if (Array.isArray(entries)) {
for (const e of entries) state[e.key] = enabled === true;
}
const json = serialize(state);
CONFIG.NOTIFICATION_ENABLED_JSON = json;
return json;
}
function setMasterEnabled(enabled) {
const value = enabled === true;
CONFIG.NOTIFICATIONS_MASTER_ENABLED = value;
return value;
}
module.exports = {
isEnabled,
isCategoryEnabled,
getAllState,
setKeyEnabled,
setCategoryEnabled,
setMasterEnabled
};

View File

@@ -1,214 +0,0 @@
/**
* Canonical notification alert registry.
*
* Single source of truth for the 32 registered alert keys across surgeChecker,
* patternChecker, staffNotifications, and chatAlertChecker. Consumed by:
* - the checker services (startup drift-check, Phase 9 enable gating)
* - routes/internalApi.js GET /notifications/alerts
* - settings-site UI (via proxied /api/notifications/alerts, with fallback)
*
* Not covered here (intentionally fallback-only in the UI):
* - rapid_t2_t3 — uses count-milestone firing, not shouldFire()
*
* `windowType` is the reset window used by shouldFire() for pattern keys
* (today/week/month). For surge, unclaimed, and chat, firing is
* cooldown-escalating rather than window-based, so windowType is null.
*/
const REGISTRY = Object.freeze({
surge: Object.freeze([
Object.freeze({
key: 'surge_tickets',
description: 'Fires when total active ticket volume exceeds configured surge thresholds, signaling broad queue pressure that needs staffing attention.',
windowType: null
}),
Object.freeze({
key: 'surge_game',
description: 'Fires when one game accumulates tickets unusually fast within the configured window, indicating a localized incident that should be triaged.',
windowType: null
}),
Object.freeze({
key: 'surge_stale',
description: 'Fires when too many tickets stay unresolved past the stale-time threshold, prompting staff to clear aging backlog.',
windowType: null
}),
Object.freeze({
key: 'surge_needs_response',
description: 'Fires when tickets needing a staff reply exceed count and age limits, indicating response latency is building.',
windowType: null
}),
Object.freeze({
key: 'surge_unclaimed',
description: 'Fires when the unclaimed queue crosses configured count/age thresholds, signaling ownership gaps that need pickup.',
windowType: null
}),
Object.freeze({
key: 'surge_tier3_unclaimed',
description: "Fires when Tier 3 tickets have been sitting unclaimed past each threshold. Escalating intervals prevent spam while ensuring critical tickets don't go unnoticed.",
windowType: null
}),
Object.freeze({
key: 'surge_no_staff',
description: 'Fires when open-ticket load is high while no staff are detected as available, prompting immediate coverage.',
windowType: null
})
]),
patterns: Object.freeze([
Object.freeze({
key: 'user_tickets',
description: 'Detects users opening unusually high ticket counts in the active window, suggesting repeat-issue or abuse patterns.',
windowType: 'today'
}),
Object.freeze({
key: 'user_reopen',
description: 'Detects users who repeatedly reopen or recreate issues after closure, signaling unresolved root-cause patterns.',
windowType: 'week'
}),
Object.freeze({
key: 'user_crossgame',
description: 'Detects users reporting similar issues across multiple games in a short period, indicating broader account-level impact.',
windowType: 'week'
}),
Object.freeze({
key: 'game_surge',
description: 'Detects game-specific ticket spikes crossing thresholds in the pattern window, signaling service instability for that title.',
windowType: 'today'
}),
Object.freeze({
key: 'game_backlog',
description: 'Detects games accumulating unresolved backlog above threshold, implying triage capacity is lagging for that queue.',
windowType: 'today'
}),
Object.freeze({
key: 'game_resolution',
description: 'Detects unusual drops in resolution rate for a game, indicating tickets are staying open longer than expected.',
windowType: 'week'
}),
Object.freeze({
key: 'game_spike',
description: 'Detects abrupt short-window jumps in ticket volume for a game, flagging incidents that may need escalation.',
windowType: 'today'
}),
Object.freeze({
key: 'tag_top',
description: 'Detects tag frequency leaders above threshold so recurring issue types can be prioritized for fixes or macros.',
windowType: 'today'
}),
Object.freeze({
key: 'tag_escalation',
description: 'Detects tags with unusually high escalation rates, indicating categories that routinely require higher-tier handling.',
windowType: 'week'
}),
Object.freeze({
key: 'untagged_closes',
description: 'Detects elevated counts of closed tickets without tags, prompting cleanup to preserve reporting quality.',
windowType: 'today'
}),
Object.freeze({
key: 'tag_game_corr',
description: 'Detects strong tag-to-game concentration patterns, highlighting issue types tightly linked to specific games.',
windowType: 'week'
}),
Object.freeze({
key: 'user_esc',
description: 'Detects users whose tickets escalate unusually often, implying complex cases that may need proactive follow-up.',
windowType: 'week'
}),
Object.freeze({
key: 'game_esc_rate',
description: 'Detects games with escalating ticket-rate thresholds exceeded, signaling deeper technical issues for that title.',
windowType: 'week'
}),
Object.freeze({
key: 'staff_no_close',
description: 'Detects staff with prolonged periods of claims but few closes, suggesting overloaded ownership or stuck work.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_overloaded',
description: 'Detects staff carrying ticket loads beyond threshold, indicating balancing or reassignment may be needed.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_stale',
description: 'Detects staff-owned tickets aging beyond stale limits, prompting review and unblock actions.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_transfer_rate',
description: 'Detects unusually high transfer/reassignment rates by staff, signaling ownership churn that may hurt throughput.',
windowType: 'today'
}),
Object.freeze({
key: 'staff_esc',
description: 'Detects staff escalation counts above threshold, highlighting where extra support or training may be needed.',
windowType: 'week'
}),
Object.freeze({
key: 'staff_game_esc',
description: 'Detects high escalation concentration for specific staff/game combinations, indicating targeted expertise gaps.',
windowType: 'week'
}),
Object.freeze({
key: 'game_tag_spike',
description: 'Detects sudden spikes of specific tags within a game, flagging focused incident signatures.',
windowType: 'today'
}),
Object.freeze({
key: 'overnight_gap',
description: 'Detects recurring unattended overnight windows with active demand, suggesting staffing coverage gaps.',
windowType: 'week'
}),
Object.freeze({
key: 'staff_always_esc',
description: 'Detects staff whose handled tickets escalate at consistently high rates, implying sustained tier-fit issues.',
windowType: 'month'
})
]),
unclaimed: Object.freeze([
Object.freeze({
key: 'unclaimed_reminder',
description: 'Reminds all staff notification channels about unclaimed tickets. Thresholds are per-ticket age — each threshold fires once per ticket and resets on escalation.',
windowType: null
})
]),
chat: Object.freeze([
Object.freeze({
key: 'chat_messages',
description: 'Fires when pending user message volume in monitored chat channels crosses configured count thresholds without staff replies.',
windowType: null
}),
Object.freeze({
key: 'chat_time',
description: 'Fires when a monitored chat channel has had no staff response for the given duration with pending user messages. Resets when staff responds.',
windowType: null
})
])
});
const ALL_KEYS = Object.freeze([
...REGISTRY.surge.map(e => e.key),
...REGISTRY.patterns.map(e => e.key),
...REGISTRY.unclaimed.map(e => e.key),
...REGISTRY.chat.map(e => e.key)
]);
const ALL_KEYS_SET = new Set(ALL_KEYS);
/**
* Throws if any of `keys` is not in the registry. Call at module load from
* each checker that references registry keys so drift fails fast.
*/
function assertKeysRegistered(moduleName, keys) {
const missing = keys.filter(k => !ALL_KEYS_SET.has(k));
if (missing.length > 0) {
throw new Error(
`[notificationRegistry] ${moduleName} references keys not in REGISTRY: ${missing.join(', ')}`
);
}
}
module.exports = { REGISTRY, ALL_KEYS, assertKeysRegistered };

View File

@@ -1,587 +0,0 @@
/**
* Pattern detection — scheduled checks that analyze ticket trends and post
* alerts to dedicated Discord channels.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { getAll, get, shouldFireThreshold, onWeeklyReset } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert keys this module fires via shouldFire()/standard threshold path.
// rapid_t2_t3 is intentionally excluded — it uses count-milestone firing below
// via firedCountMilestones, not the shouldFire() pipeline, so it is not part
// of the notification registry.
const PATTERN_ALERT_KEYS = [
'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',
'staff_no_close', 'staff_overloaded', 'staff_stale', 'staff_transfer_rate',
'staff_esc', 'staff_game_esc',
'game_tag_spike', 'overnight_gap', 'staff_always_esc'
];
assertKeysRegistered('patternChecker', PATTERN_ALERT_KEYS);
const Ticket = mongoose.model('Ticket');
// rapid_t2_t3 count milestone state (cleared weekly)
const firedCountMilestones = new Map();
onWeeklyReset(() => firedCountMilestones.clear());
// --- Helpers ---
function buildEmbed(title, description, color = 0xFFAA00) {
return new EmbedBuilder()
.setTitle(title)
.setDescription(String(description).slice(0, 4000))
.setColor(color)
.setTimestamp();
}
async function postPattern(client, channelConfigKey, embed) {
const channelId = CONFIG[channelConfigKey];
if (!channelId || !client) return;
try {
const channel = await client.channels.fetch(channelId);
if (channel) await enqueueSend(channel, { embeds: [embed] });
} catch (_) {}
}
function getWindowStartMs(windowType) {
if (windowType === 'today') {
const start = new Date();
start.setHours(0, 0, 0, 0);
return start.getTime();
}
if (windowType === 'week') return getThisWeekStart().getTime();
if (windowType === 'month') {
const start = new Date();
start.setDate(1);
start.setHours(0, 0, 0, 0);
return start.getTime();
}
return Date.now();
}
function shouldFire(alertKey, key, windowType) {
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0);
if (thresholds.length === 0) return false;
const ageMs = Date.now() - getWindowStartMs(windowType);
return shouldFireThreshold(key, ageMs, thresholds, windowType) !== null;
}
function getThisWeekStart() {
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? 6 : day - 1;
const monday = new Date(now);
monday.setDate(now.getDate() - diff);
monday.setHours(0, 0, 0, 0);
return monday;
}
// --- Check functions ---
async function checkUserPatterns(client) {
// Surge: users with tickets >= threshold today
const todayCounts = getAll('user_tickets', 'today');
for (const [userId, count] of todayCounts) {
if (count >= CONFIG.PATTERN_USER_TICKET_THRESHOLD) {
const key = `user_tickets:${userId}:today`;
if (isEnabled('user_tickets') && shouldFire('user_tickets', key, 'today')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Repeat ticket user',
`User \`${userId}\` created ${count} tickets today (threshold: ${CONFIG.PATTERN_USER_TICKET_THRESHOLD}).`,
0xFFAA00
));
}
}
}
// Reopens this week
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
try {
const reopens = await Ticket.aggregate([
{ $match: { reopenedAt: { $gte: since } } },
{ $group: { _id: '$senderEmail', count: { $sum: 1 } } },
{ $match: { count: { $gte: 2 } } }
]);
for (const r of reopens) {
const key = `user_reopen:${r._id}:week`;
if (isEnabled('user_reopen') && shouldFire('user_reopen', key, 'week')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'High reopen rate',
`${r._id} reopened tickets ${r.count}x this week`,
0xFFAA00
));
}
}
} catch (_) {}
// Cross-game: users with tickets across 3+ games this week
try {
const crossGame = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, status: { $ne: 'closed' } } },
{ $group: { _id: '$senderEmail', games: { $addToSet: '$game' } } },
{ $match: { 'games.2': { $exists: true } } }
]);
for (const c of crossGame) {
const key = `user_crossgame:${c._id}:week`;
if (isEnabled('user_crossgame') && shouldFire('user_crossgame', key, 'week')) {
postPattern(client, 'USER_PATTERNS_CHANNEL_ID', buildEmbed(
'Cross-game user',
`${c._id} has tickets across ${c.games.length} games: ${c.games.filter(Boolean).join(', ')}`,
0x00AAFF
));
}
}
} catch (_) {}
}
async function checkGamePatterns(client) {
// Surge: games with tickets >= threshold today
const todayCounts = getAll('game_tickets', 'today');
for (const [game, count] of todayCounts) {
if (count >= CONFIG.PATTERN_GAME_TICKET_THRESHOLD) {
const key = `game_surge:${game}:today`;
if (isEnabled('game_surge') && shouldFire('game_surge', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game ticket surge',
`**${game}** has ${count} tickets today (threshold: ${CONFIG.PATTERN_GAME_TICKET_THRESHOLD}).`,
0xFF6600
));
}
}
}
// Backlog: unclaimed tickets older than threshold
try {
const cutoff = new Date(Date.now() - CONFIG.PATTERN_UNCLAIMED_HOURS * 3600000);
const backlog = await Ticket.aggregate([
{ $match: { status: 'open', claimedBy: null, createdAt: { $lte: cutoff } } },
{ $group: { _id: '$game', count: { $sum: 1 } } },
{ $match: { count: { $gte: 3 } } }
]);
for (const b of backlog) {
const gameName = b._id || 'Unknown';
const key = `game_backlog:${gameName}:today`;
if (isEnabled('game_backlog') && shouldFire('game_backlog', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Game backlog alert',
`**${gameName}** has ${b.count} unclaimed tickets older than ${CONFIG.PATTERN_UNCLAIMED_HOURS}h.`,
0xFF0000
));
}
}
} catch (_) {}
// Resolution time trending: this week vs last week
try {
const thisWeekStart = getThisWeekStart();
const lastWeekStart = new Date(thisWeekStart.getTime() - 7 * 24 * 60 * 60 * 1000);
const thisWeek = await Ticket.aggregate([
{ $match: { status: 'closed', closedAt: { $gte: thisWeekStart }, game: { $ne: null } } },
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
]);
const lastWeek = await Ticket.aggregate([
{ $match: { status: 'closed', closedAt: { $gte: lastWeekStart, $lt: thisWeekStart }, game: { $ne: null } } },
{ $group: { _id: '$game', avg: { $avg: { $subtract: ['$closedAt', '$createdAt'] } } } }
]);
const lastWeekMap = new Map(lastWeek.map(l => [l._id, l.avg]));
for (const tw of thisWeek) {
const lw = lastWeekMap.get(tw._id);
if (lw && tw.avg > lw * 1.2) {
const key = `game_resolution:${tw._id}:week`;
if (isEnabled('game_resolution') && shouldFire('game_resolution', key, 'week')) {
const twHrs = (tw.avg / 3600000).toFixed(1);
const lwHrs = (lw / 3600000).toFixed(1);
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Resolution time increasing',
`**${tw._id}**: ${twHrs}h avg this week vs ${lwHrs}h last week (+${((tw.avg / lw - 1) * 100).toFixed(0)}%).`,
0xFFAA00
));
}
}
}
} catch (_) {}
// Spike after silence: games with 0 tickets in last 3 days but 3+ today
try {
const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
const recentByGame = await Ticket.aggregate([
{ $match: { createdAt: { $gte: threeDaysAgo, $lt: todayStart }, game: { $ne: null } } },
{ $group: { _id: '$game', count: { $sum: 1 } } }
]);
const recentGames = new Set(recentByGame.map(r => r._id));
for (const [game, count] of todayCounts) {
if (count >= 3 && !recentGames.has(game)) {
const key = `game_spike:${game}:today`;
if (isEnabled('game_spike') && shouldFire('game_spike', key, 'today')) {
postPattern(client, 'GAME_PATTERNS_CHANNEL_ID', buildEmbed(
'Possible outage',
`**${game}**: ${count} tickets today after 0 in the last 3 days.`,
0xFF0000
));
}
}
}
} catch (_) {}
}
async function checkTagPatterns(client) {
// Most common tag today
const todayTags = getAll('tag_usage', 'today');
let topTag = null, topCount = 0;
for (const [tag, count] of todayTags) {
if (count > topCount) { topTag = tag; topCount = count; }
}
if (topTag && topCount >= 5) {
const key = `tag_top:${topTag}:today`;
if (isEnabled('tag_top') && shouldFire('tag_top', key, 'today')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Top issue tag today',
`**${topTag}** used ${topCount} times today.`,
0x00AAFF
));
}
}
// Tag→escalation correlation
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const tagEscalations = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, escalationTier: { $gte: 1 }, ticketTag: { $ne: null } } },
{ $group: { _id: '$ticketTag', count: { $sum: 1 } } },
{ $match: { count: { $gte: 3 } } }
]);
for (const te of tagEscalations) {
const key = `tag_escalation:${te._id}:week`;
if (isEnabled('tag_escalation') && shouldFire('tag_escalation', key, 'week')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'Tag frequently leads to escalation',
`**${te._id}**: ${te.count} escalated tickets this week.`,
0xFFAA00
));
}
}
} catch (_) {}
// Untagged closes
const untaggedCount = get('untagged_closes', 'total', 'today');
if (untaggedCount >= 5) {
const key = 'untagged_closes:today';
if (isEnabled('untagged_closes') && shouldFire('untagged_closes', key, 'today')) {
postPattern(client, 'TAG_PATTERNS_CHANNEL_ID', buildEmbed(
'High untagged close rate',
`${untaggedCount} tickets closed today without a tag.`,
0xFFAA00
));
}
}
// Tag↔game correlation: for each tag this week, check if one game dominates
const weekTags = getAll('tag_usage', 'week');
for (const [tag] of weekTags) {
const tagGameCounts = getAll(`tag_game:${tag}`, 'week');
let total = 0, maxGame = null, maxCount = 0;
for (const [game, count] of tagGameCounts) {
total += count;
if (count > maxCount) { maxGame = game; maxCount = count; }
}
if (total >= 5 && maxGame && maxCount / total > 0.8) {
const key = `tag_game_corr:${tag}:${maxGame}:week`;
if (isEnabled('tag_game_corr') && shouldFire('tag_game_corr', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Auto-tagging opportunity',
`**${tag}** is ${Math.round(maxCount / total * 100)}% from **${maxGame}** (${maxCount}/${total} this week).`,
0x00AAFF
));
}
}
}
}
async function checkEscalationPatterns(client) {
// User escalation rate
const userEscalations = getAll('user_escalations', 'week');
for (const [user, count] of userEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `user_esc:${user}:week`;
if (isEnabled('user_esc') && shouldFire('user_esc', key, 'week')) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Frequent escalation user',
`\`${user}\` has ${count} escalated tickets this week (threshold: ${CONFIG.PATTERN_ESCALATION_THRESHOLD}).`,
0xFFAA00
));
}
}
}
// Game escalation rate vs baseline
try {
const thisWeekStart = getThisWeekStart();
const thisWeek = await Ticket.aggregate([
{ $match: { escalationTier: { $gte: 1 }, createdAt: { $gte: thisWeekStart } } },
{ $group: { _id: '$game', count: { $sum: 1 } } }
]);
const totalThisWeek = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart } });
for (const tw of thisWeek) {
if (!tw._id) continue;
const gameTotal = await Ticket.countDocuments({ createdAt: { $gte: thisWeekStart }, game: tw._id });
if (gameTotal > 0 && tw.count / gameTotal > 0.5) {
const key = `game_esc_rate:${tw._id}:week`;
if (isEnabled('game_esc_rate') && shouldFire('game_esc_rate', key, 'week')) {
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'High escalation rate for game',
`**${tw._id}**: ${tw.count}/${gameTotal} tickets escalated (${Math.round(tw.count / gameTotal * 100)}%) this week.`,
0xFF6600
));
}
}
}
} catch (_) {}
// Rapid tier 2→3
if (!isEnabled('rapid_t2_t3')) return;
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const rapid = await Ticket.find({
escalationTier: 2,
escalatedAt: { $gte: since }
}).lean();
// Count tickets where escalation happened very quickly (approximate: check if tier was changed recently)
const rapidCount = rapid.length;
if (rapidCount >= 3) {
const key = 'rapid_t2_t3:week';
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.rapid_t2_t3) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.sort((a, b) => a - b);
const firedSet = firedCountMilestones.get(key) || new Set();
let shouldNotify = false;
for (const threshold of thresholds) {
if (rapidCount >= threshold && !firedSet.has(threshold)) {
firedSet.add(threshold);
shouldNotify = true;
break;
}
}
if (shouldNotify) {
firedCountMilestones.set(key, firedSet);
postPattern(client, 'ESCALATION_PATTERNS_CHANNEL_ID', buildEmbed(
'Rapid tier 3 escalations',
`${rapidCount} tickets reached tier 3 this week.`,
0xFF0000
));
}
}
} catch (_) {}
}
async function checkStaffPatterns(client) {
// Claims without closes
const todayClaims = getAll('staff_claims', 'today');
for (const [staffId, claims] of todayClaims) {
if (claims >= 3 && get('staff_closes', staffId, 'today') === 0) {
const key = `staff_no_close:${staffId}:today`;
if (isEnabled('staff_no_close') && shouldFire('staff_no_close', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Claims without closes',
`Staff \`${staffId}\` claimed ${claims} tickets today but closed 0.`,
0xFFAA00
));
}
}
}
// Overloaded: open tickets per claimer
try {
const overloaded = await Ticket.aggregate([
{ $match: { status: 'open', claimerId: { $ne: null } } },
{ $group: { _id: '$claimerId', count: { $sum: 1 } } },
{ $match: { count: { $gte: 5 } } }
]);
for (const o of overloaded) {
const key = `staff_overloaded:${o._id}:today`;
if (isEnabled('staff_overloaded') && shouldFire('staff_overloaded', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff overloaded',
`Staff \`${o._id}\` has ${o.count} open claimed tickets.`,
0xFF6600
));
}
}
} catch (_) {}
// Stale ping threshold
const stalePings = getAll('staff_stale_pings', 'today');
for (const [staffId, count] of stalePings) {
if (count >= CONFIG.PATTERN_STAFF_STALE_PING_THRESHOLD) {
const key = `staff_stale:${staffId}:today`;
if (isEnabled('staff_stale') && shouldFire('staff_stale', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff stale ping threshold',
`Staff \`${staffId}\` received ${count} stale pings today.`,
0xFFAA00
));
}
}
}
// Transfer rate
const todayTransfers = getAll('staff_transfers', 'today');
for (const [staffId, transfers] of todayTransfers) {
const claims = get('staff_claims', staffId, 'today');
if (claims > 0 && transfers >= claims) {
const key = `staff_transfer_rate:${staffId}:today`;
if (isEnabled('staff_transfer_rate') && shouldFire('staff_transfer_rate', key, 'today')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'High transfer rate',
`Staff \`${staffId}\` transferred ${transfers}/${claims} claimed tickets today.`,
0xFFAA00
));
}
}
}
// Escalations per staff
const weekEscalations = getAll('staff_escalations', 'week');
for (const [staffId, count] of weekEscalations) {
if (count >= CONFIG.PATTERN_ESCALATION_THRESHOLD) {
const key = `staff_esc:${staffId}:week`;
if (isEnabled('staff_esc') && shouldFire('staff_esc', key, 'week')) {
postPattern(client, 'STAFF_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff frequent escalator',
`Staff \`${staffId}\` escalated ${count} tickets this week.`,
0xFFAA00
));
}
}
}
}
async function checkCombinedPatterns(client) {
// Staff+game escalation correlation
const weekEscStaff = getAll('staff_escalations', 'week');
for (const [staffId] of weekEscStaff) {
const gameEsc = getAll(`staff_game_escalations:${staffId}`, 'week');
for (const [game, count] of gameEsc) {
if (count >= 3) {
const key = `staff_game_esc:${staffId}:${game}:week`;
if (isEnabled('staff_game_esc') && shouldFire('staff_game_esc', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff may need training for this game',
`Staff \`${staffId}\` escalated ${count} **${game}** tickets this week.`,
0xFFAA00
));
}
}
}
}
// Game+tag spike: specific game+tag combo >= 5 today
const todayGames = getAll('game_tickets', 'today');
const todayTags = getAll('tag_usage', 'today');
for (const [game] of todayGames) {
for (const [tag] of todayTags) {
const tagGameCount = get(`tag_game:${tag}`, game, 'week');
if (tagGameCount >= 5) {
const key = `game_tag_spike:${game}:${tag}:today`;
if (isEnabled('game_tag_spike') && shouldFire('game_tag_spike', key, 'today')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Specific feature of specific game spiking',
`**${game}** + **${tag}**: ${tagGameCount} tickets this week.`,
0xFF6600
));
}
}
}
}
// Overnight escalation gap: compare 00:00-06:00 vs daytime escalation rates
try {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const overnight = await Ticket.countDocuments({
createdAt: { $gte: since },
escalationTier: { $gte: 1 },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
});
const daytime = await Ticket.countDocuments({
createdAt: { $gte: since },
escalationTier: { $gte: 1 },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
});
const overnightTotal = await Ticket.countDocuments({
createdAt: { $gte: since },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 0] }, { $lt: [{ $hour: '$createdAt' }, 6] }] }
});
const daytimeTotal = await Ticket.countDocuments({
createdAt: { $gte: since },
$expr: { $and: [{ $gte: [{ $hour: '$createdAt' }, 6] }, { $lt: [{ $hour: '$createdAt' }, 24] }] }
});
if (overnightTotal > 0 && daytimeTotal > 0) {
const overnightRate = overnight / overnightTotal;
const daytimeRate = daytime / daytimeTotal;
if (overnightRate > daytimeRate * 2 && overnight >= 3) {
const key = 'overnight_gap:week';
if (isEnabled('overnight_gap') && shouldFire('overnight_gap', key, 'week')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Overnight coverage gap',
`Overnight escalation rate: ${Math.round(overnightRate * 100)}% vs daytime ${Math.round(daytimeRate * 100)}%.`,
0xFF0000
));
}
}
}
} catch (_) {}
// Staff never resolves game X without escalating
try {
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const staffGameStats = await Ticket.aggregate([
{ $match: { claimerId: { $ne: null }, game: { $ne: null }, createdAt: { $gte: monthStart } } },
{ $group: {
_id: { staff: '$claimerId', game: '$game' },
total: { $sum: 1 },
escalated: { $sum: { $cond: [{ $gte: ['$escalationTier', 1] }, 1, 0] } }
}},
{ $match: { total: { $gte: 3 } } }
]);
for (const s of staffGameStats) {
if (s.escalated / s.total >= 0.9) {
const key = `staff_always_esc:${s._id.staff}:${s._id.game}:month`;
if (isEnabled('staff_always_esc') && shouldFire('staff_always_esc', key, 'month')) {
postPattern(client, 'COMBINED_PATTERNS_CHANNEL_ID', buildEmbed(
'Staff always escalates this game',
`Staff \`${s._id.staff}\` escalated ${s.escalated}/${s.total} **${s._id.game}** tickets this month.`,
0xFF6600
));
}
}
}
} catch (_) {}
}
// --- Main entry point ---
async function runPatternChecks(client) {
try { await checkUserPatterns(client); } catch (e) { console.error('checkUserPatterns:', e); }
try { await checkGamePatterns(client); } catch (e) { console.error('checkGamePatterns:', e); }
try { await checkTagPatterns(client); } catch (e) { console.error('checkTagPatterns:', e); }
try { await checkEscalationPatterns(client); } catch (e) { console.error('checkEscalationPatterns:', e); }
try { await checkStaffPatterns(client); } catch (e) { console.error('checkStaffPatterns:', e); }
try { await checkCombinedPatterns(client); } catch (e) { console.error('checkCombinedPatterns:', e); }
}
module.exports = { runPatternChecks };

View File

@@ -1,286 +0,0 @@
/**
* In-memory counter store with TTL windows for pattern detection.
* Windows: 'today' resets at midnight, 'week' resets Monday 00:00, 'month' resets 1st 00:00.
*/
// store[window][namespace][key] = count
const store = {
today: new Map(),
week: new Map(),
month: new Map()
};
function getNamespaceMap(window, namespace) {
const windowMap = store[window];
if (!windowMap) return null;
if (!windowMap.has(namespace)) windowMap.set(namespace, new Map());
return windowMap.get(namespace);
}
function increment(namespace, key, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return;
map.set(key, (map.get(key) || 0) + 1);
}
function get(namespace, key, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return 0;
return map.get(key) || 0;
}
function reset(namespace, window) {
const windowMap = store[window];
if (!windowMap) return;
windowMap.delete(namespace);
}
function getAll(namespace, window) {
const map = getNamespaceMap(window, namespace);
if (!map) return new Map();
return new Map(map);
}
// --- Scheduled resets ---
function msUntilNextMidnight() {
const now = new Date();
const next = new Date(now);
next.setHours(24, 0, 0, 0);
return next.getTime() - now.getTime();
}
function msUntilNextMonday() {
const now = new Date();
const day = now.getDay(); // 0=Sun
const daysUntilMonday = day === 0 ? 1 : (8 - day);
const next = new Date(now);
next.setDate(now.getDate() + daysUntilMonday);
next.setHours(0, 0, 0, 0);
return next.getTime() - now.getTime();
}
function msUntilNextMonth() {
const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
return next.getTime() - now.getTime();
}
// Callbacks to run on daily reset (e.g. clear firedToday in patternChecker)
const dailyResetCallbacks = [];
const weeklyResetCallbacks = [];
function onDailyReset(fn) {
dailyResetCallbacks.push(fn);
}
function onWeeklyReset(fn) {
weeklyResetCallbacks.push(fn);
}
// --- Threshold firing state ---
// key -> Set<thresholdMs> that have fired within the key's window.
const firedThresholds = new Map();
// key -> window type used for threshold clearing ("today" | "week" | "month")
const firedThresholdWindows = new Map();
// key -> last-seen timestamp; drives periodic sweep for keys that outlive their window reset.
const firedThresholdLastSeen = new Map();
function clearFiredThresholdsForWindow(windowType) {
for (const [key, mappedWindowType] of firedThresholdWindows.entries()) {
if (mappedWindowType === windowType) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
}
}
}
function shouldFireThreshold(key, ageMs, thresholdsMs, windowType) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
if (!['today', 'week', 'month'].includes(windowType)) return null;
firedThresholdWindows.set(key, windowType);
firedThresholdLastSeen.set(key, Date.now());
const firedForKey = firedThresholds.get(key) || new Set();
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
let highestUnfiredCrossed = null;
for (const thresholdMs of sortedThresholds) {
if (ageMs >= thresholdMs && !firedForKey.has(thresholdMs)) {
highestUnfiredCrossed = thresholdMs;
}
}
if (highestUnfiredCrossed === null) return null;
firedForKey.add(highestUnfiredCrossed);
firedThresholds.set(key, firedForKey);
return highestUnfiredCrossed;
}
// --- Escalating cooldown state ---
// key -> { startedAtMs, lastFireAtMs, fireCount }
const escalatingCooldowns = new Map();
function shouldFireCooldownEscalating(key, thresholdsMs) {
if (!Array.isArray(thresholdsMs) || thresholdsMs.length === 0) return null;
const sortedThresholds = [...thresholdsMs].sort((a, b) => a - b);
const now = Date.now();
let state = escalatingCooldowns.get(key);
if (!state) {
state = { startedAtMs: now, lastFireAtMs: null, fireCount: 0, lastUsed: now };
escalatingCooldowns.set(key, state);
}
state.lastUsed = now;
const nextThreshold = sortedThresholds[state.fireCount];
if (typeof nextThreshold !== 'number') return null;
const referenceMs = state.fireCount === 0 ? state.startedAtMs : state.lastFireAtMs;
if ((now - referenceMs) < nextThreshold) return null;
state.fireCount += 1;
state.lastFireAtMs = now;
return nextThreshold;
}
function clearEscalating(key) {
escalatingCooldowns.delete(key);
}
const SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function cleanupStaleEscalatingCooldowns(now = Date.now()) {
const cutoff = now - SWEEP_TTL_MS;
for (const [key, state] of escalatingCooldowns.entries()) {
const lastUsed = state.lastUsed || state.lastFireAtMs || state.startedAtMs || 0;
if (lastUsed < cutoff) escalatingCooldowns.delete(key);
}
}
// Sweep every per-Map timestamp-bearing entry older than SWEEP_TTL_MS.
// firedThresholds/firedThresholdWindows are cleared by windowType-resets;
// this sweep covers keys whose window never resets under load.
function sweepPatternStore(now = Date.now()) {
const cutoff = now - SWEEP_TTL_MS;
for (const [key, ts] of cooldowns.entries()) {
if (ts < cutoff) cooldowns.delete(key);
}
for (const [key, ts] of staffLastSeen.entries()) {
if (ts < cutoff) staffLastSeen.delete(key);
}
cleanupStaleEscalatingCooldowns(now);
for (const [key, ts] of firedThresholdLastSeen.entries()) {
if (ts < cutoff) {
firedThresholds.delete(key);
firedThresholdWindows.delete(key);
firedThresholdLastSeen.delete(key);
}
}
}
/**
* Register the module's sweep on the given trackInterval function.
* Called once from the ready handler. Interval is unref'd so it never
* blocks shutdown; trackInterval ensures handleShutdown clears it.
*/
function startSweeps(trackInterval) {
const handle = setInterval(() => sweepPatternStore(), SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
function scheduleDailyReset() {
setTimeout(() => {
store.today = new Map();
clearFiredThresholdsForWindow('today');
for (const fn of dailyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleDailyReset();
}, msUntilNextMidnight());
}
function scheduleWeeklyReset() {
setTimeout(() => {
store.week = new Map();
clearFiredThresholdsForWindow('week');
for (const fn of weeklyResetCallbacks) {
try { fn(); } catch (_) {}
}
scheduleWeeklyReset();
}, msUntilNextMonday());
}
function scheduleMonthlyReset() {
setTimeout(() => {
store.month = new Map();
clearFiredThresholdsForWindow('month');
scheduleMonthlyReset();
}, msUntilNextMonth());
}
function scheduleResets() {
scheduleDailyReset();
scheduleWeeklyReset();
scheduleMonthlyReset();
}
// --- Cooldown store ---
const cooldowns = new Map();
function setCooldown(key) {
cooldowns.set(key, Date.now());
}
function isOnCooldown(key, cooldownMinutes) {
const last = cooldowns.get(key);
if (!last) return false;
return (Date.now() - last) < cooldownMinutes * 60 * 1000;
}
// --- Staff last-seen tracker (fallback for missing presence intent) ---
const staffLastSeen = new Map();
function updateStaffLastSeen(staffId) {
staffLastSeen.set(staffId, Date.now());
}
function getStaffLastSeen(staffId) {
return staffLastSeen.get(staffId) || null;
}
function isStaffRecentlyActive(staffId, withinMinutes = 60) {
const last = staffLastSeen.get(staffId);
if (!last) return false;
return (Date.now() - last) < withinMinutes * 60 * 1000;
}
module.exports = {
increment,
get,
reset,
getAll,
scheduleResets,
onDailyReset,
onWeeklyReset,
firedThresholds,
shouldFireThreshold,
shouldFireCooldownEscalating,
clearEscalating,
setCooldown,
isOnCooldown,
updateStaffLastSeen,
getStaffLastSeen,
isStaffRecentlyActive,
startSweeps,
sweepPatternStore,
// test-only exports
_internals: { cooldowns, staffLastSeen, escalatingCooldowns, firedThresholds, firedThresholdWindows, firedThresholdLastSeen, SWEEP_TTL_MS }
};

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

@@ -1,89 +0,0 @@
const { CONFIG } = require('../config');
const { enqueueSend } = require('./channelQueue');
/**
* Create a staff tracking channel for a ticket.
* Returns the created channel or null if no staff category configured.
*/
async function createStaffChannel(guild, ticket, claimerId, channelName) {
const categoryId = CONFIG.STAFF_CATEGORIES.get(claimerId);
if (!categoryId) return null;
try {
const { ChannelType } = require('discord.js');
const staffChan = await guild.channels.create({
name: channelName,
type: ChannelType.GuildText,
parent: categoryId
});
// Build pinned embed with ticket info + jump link to original ticket channel
const { EmbedBuilder } = require('discord.js');
const originalChannel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
const jumpLink = originalChannel ? `https://discord.com/channels/${guild.id}/${ticket.discordThreadId}` : null;
const embed = new EmbedBuilder()
.setTitle(`🎫 Ticket #${ticket.ticketNumber}`)
.setColor(0x5865f2)
.addFields(
{ name: 'Customer', value: ticket.senderEmail || 'Unknown', inline: true },
{ name: 'Game', value: ticket.game || 'Not detected', inline: true },
{ name: 'Subject', value: ticket.subject || 'No subject', inline: false },
{ name: 'Original Ticket', value: jumpLink ? `[Jump to ticket](${jumpLink})` : 'Unknown', inline: false }
)
.setFooter({ text: `Claimed by ${ticket.claimedBy || 'Unknown'}` })
.setTimestamp();
const pinMsg = await enqueueSend(staffChan, { embeds: [embed] });
await pinMsg.pin().catch(() => {});
return staffChan;
} catch (e) {
console.error('Failed to create staff channel:', e);
return null;
}
}
/**
* Ping the staff channel with a customer reply, including jump link and message copy.
*/
async function pingStaffChannel(staffChannel, claimerId, originalMessage) {
if (!staffChannel) return;
try {
const jumpLink = `https://discord.com/channels/${originalMessage.guild.id}/${originalMessage.channel.id}/${originalMessage.id}`;
await enqueueSend(staffChannel,
`<@${claimerId}> Customer replied in ticket:\n> ${originalMessage.content.slice(0, 500)}\n[Jump to message](${jumpLink})`
);
} catch (e) {
console.error('Failed to ping staff channel:', e);
}
}
/**
* Move staff channel to a different category.
*/
async function moveStaffChannel(staffChannel, categoryId) {
if (!staffChannel || !categoryId) return;
try {
const { enqueueMove } = require('./channelQueue');
await enqueueMove(staffChannel, categoryId);
} catch (e) {
console.error('Failed to move staff channel:', e);
}
}
/**
* Delete the staff tracking channel.
*/
async function deleteStaffChannel(guild, staffChannelId) {
if (!staffChannelId) return;
try {
const chan = await guild.channels.fetch(staffChannelId).catch(() => null);
// TODO(queue-migrate): raw channel.delete bypasses channelQueue (enqueueDelete) — if a staff-channel send is in-flight, this can race it.
if (chan) await chan.delete();
} catch (e) {
console.error('Failed to delete staff channel:', e);
}
}
module.exports = { createStaffChannel, pingStaffChannel, moveStaffChannel, deleteStaffChannel };

View File

@@ -1,149 +0,0 @@
/**
* Staff notification service reply alerts and unclaimed ticket reminders.
*
* notifyStaffOfReply: posts in the claimer's notification channel when a
* non-staff user replies, respecting a per-staff cooldown.
*
* notifyAllStaffUnclaimed: background job that checks unclaimed tickets
* against configurable hour thresholds and posts one alert per threshold
* per ticket (highest newly-crossed threshold only).
*/
const { mongoose } = require('../db-connection');
const { CONFIG, parseThresholdString } = require('../config');
const { increment } = require('./patternStore');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert key this module drives. Registered to fail fast on drift.
const UNCLAIMED_ALERT_KEYS = ['unclaimed_reminder'];
assertKeysRegistered('staffNotifications', UNCLAIMED_ALERT_KEYS);
const Ticket = mongoose.model('Ticket');
const StaffNotification = mongoose.model('StaffNotification');
// In-memory cooldown map: `${userId}:${ticketId}` -> last notified timestamp
const replyCooldowns = new Map();
const REPLY_COOLDOWN_SWEEP_TTL_MS = 48 * 60 * 60 * 1000;
const REPLY_COOLDOWN_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000;
function sweepReplyCooldowns(now = Date.now()) {
const cutoff = now - REPLY_COOLDOWN_SWEEP_TTL_MS;
for (const [key, ts] of replyCooldowns.entries()) {
if (ts < cutoff) replyCooldowns.delete(key);
}
}
function startSweeps(trackInterval) {
const handle = setInterval(() => sweepReplyCooldowns(), REPLY_COOLDOWN_SWEEP_INTERVAL_MS);
if (typeof handle.unref === 'function') handle.unref();
if (typeof trackInterval === 'function') trackInterval(handle);
return handle;
}
/**
* Notify the claiming staff member when a non-staff user replies.
* Respects the staff member's cooldownHours setting (default 1h).
* Posts in their notification channel if one exists.
*/
async function notifyStaffOfReply(guild, ticket, message) {
if (!ticket.claimerId) return;
const staffRecord = await StaffNotification.findOne({ userId: ticket.claimerId }).lean();
if (!staffRecord?.channelId) return;
const cooldownMs = (staffRecord.cooldownHours || 1) * 60 * 60 * 1000;
const cooldownKey = `${ticket.claimerId}:${ticket.gmailThreadId}`;
const lastNotified = replyCooldowns.get(cooldownKey) || 0;
if (Date.now() - lastNotified < cooldownMs) return;
const notifChannel = await guild.channels.fetch(staffRecord.channelId).catch(() => null);
if (!notifChannel) return;
const jumpLink = `https://discord.com/channels/${guild.id}/${message.channel.id}/${message.id}`;
const snippet = message.content?.slice(0, 300) || '(no text)';
await enqueueSend(
notifChannel,
`New reply in **${message.channel.name}** from ${message.author.tag}:\n> ${snippet}\n[Jump to message](${jumpLink})`
);
replyCooldowns.set(cooldownKey, Date.now());
}
/**
* Background job: check all open unclaimed tickets against hour thresholds.
* For each ticket, find the highest threshold that has been crossed but not
* yet recorded. Post one notification per ticket per run (the highest new
* threshold) into every staff notification channel.
*/
async function notifyAllStaffUnclaimed(client) {
if (!isEnabled('unclaimed_reminder')) return;
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS.unclaimed_reminder) || [];
const thresholds = rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.map(ms => ms / (60 * 60 * 1000));
if (thresholds.length === 0) return;
const sorted = [...thresholds].sort((a, b) => a - b);
const now = Date.now();
// Bounded per-tick: oldest-first, capped at 500. A backlog larger than 500
// gets drained in subsequent 30-minute ticks rather than one long run.
const unclaimedTickets = await Ticket.find({
status: 'open',
claimedBy: null,
createdAt: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean();
if (unclaimedTickets.length === 0) return;
const staffRecords = await StaffNotification.find({ channelId: { $ne: null } }).lean();
if (staffRecords.length === 0) return;
const guild = CONFIG.DISCORD_GUILD_ID
? client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID)
: client.guilds.cache.first();
if (!guild) return;
for (const ticket of unclaimedTickets) {
const ageMs = now - new Date(ticket.createdAt).getTime();
const ageHours = ageMs / (60 * 60 * 1000);
const alreadySent = ticket.unclaimedRemindersSent || [];
// Find thresholds crossed but not yet sent
const crossedNew = sorted.filter(t => ageHours >= t && !alreadySent.includes(t));
if (crossedNew.length === 0) continue;
// Only send the highest newly-crossed threshold
const highest = crossedNew[crossedNew.length - 1];
const channelName = ticket.discordThreadId
? `<#${ticket.discordThreadId}>`
: `ticket #${ticket.ticketNumber}`;
const alertMsg = `[${highest}h+ unclaimed] ${channelName}`;
for (const rec of staffRecords) {
const chan = await guild.channels.fetch(rec.channelId).catch(() => null);
if (chan) {
await enqueueSend(chan, alertMsg).catch(e => console.error('Unclaimed notify send:', e));
increment('staff_stale_pings', rec.userId, 'today');
increment('staff_stale_pings', rec.userId, 'week');
}
}
await Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId },
{ $addToSet: { unclaimedRemindersSent: highest } }
);
}
}
module.exports = {
notifyStaffOfReply,
notifyAllStaffUnclaimed,
startSweeps,
sweepReplyCooldowns,
_internals: { replyCooldowns, REPLY_COOLDOWN_SWEEP_TTL_MS }
};

View File

@@ -1,48 +0,0 @@
/**
* Staff presence detection — checks Discord presence status for staff members.
* Requires GuildPresences intent enabled in Discord Developer Portal.
*/
const { CONFIG } = require('../config');
/**
* Get categorized availability of all configured staff members.
* @param {import('discord.js').Guild} guild
* @returns {{ online: string[], dnd: string[], offline: string[], unknown: string[] }}
*/
function getStaffAvailability(guild) {
const results = {
online: [],
dnd: [],
offline: [],
unknown: []
};
for (const staffId of CONFIG.STAFF_IDS) {
const member = guild.members.cache.get(staffId);
if (!member) { results.offline.push(staffId); continue; }
const status = member.presence?.status;
if (!status) { results.unknown.push(staffId); continue; }
if (status === 'online' || status === 'idle') results.online.push(staffId);
else if (status === 'dnd') results.dnd.push(staffId);
else results.offline.push(staffId);
}
return results;
}
/**
* Check if any staff member is currently available.
* @param {import('discord.js').Guild} guild
* @returns {{ available: boolean|null, source: string }}
*/
function isAnyStaffAvailable(guild) {
const { online, dnd, unknown } = getStaffAvailability(guild);
if (online.length > 0) return { available: true, source: 'presence' };
if (CONFIG.STAFF_DND_COUNTS_AS_AVAILABLE && dnd.length > 0) return { available: true, source: 'presence_dnd' };
if (unknown.length === CONFIG.STAFF_IDS.length) return { available: null, source: 'unknown' };
return { available: false, source: 'presence' };
}
module.exports = { getStaffAvailability, isAnyStaffAvailable };

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

@@ -1,260 +0,0 @@
/**
* Surge detection — checks for critical ticket volume/staffing conditions
* and pings ALL_STAFF_CHANNEL_ID with role mention.
*/
const { EmbedBuilder } = require('discord.js');
const { CONFIG, parseThresholdString } = require('../config');
const { mongoose } = require('../db-connection');
const { shouldFireCooldownEscalating, clearEscalating, isStaffRecentlyActive } = require('./patternStore');
const { getStaffAvailability, isAnyStaffAvailable } = require('./staffPresence');
const { enqueueSend } = require('./channelQueue');
const { assertKeysRegistered } = require('./notificationRegistry');
const { isEnabled } = require('./notificationEnabled');
// Alert keys this module drives. Asserted against the registry at load so any
// future drift (rename, typo, unregistered key) fails fast rather than
// silently breaking the settings-site config editor.
const SURGE_ALERT_KEYS = [
'surge_tickets',
'surge_game',
'surge_stale',
'surge_needs_response',
'surge_unclaimed',
'surge_tier3_unclaimed',
'surge_no_staff'
];
assertKeysRegistered('surgeChecker', SURGE_ALERT_KEYS);
const Ticket = mongoose.model('Ticket');
function getThresholdsMs(alertKey) {
const rawThresholds = (CONFIG.NOTIFICATION_THRESHOLDS && CONFIG.NOTIFICATION_THRESHOLDS[alertKey]) || [];
return rawThresholds
.map(parseThresholdString)
.filter(n => Number.isFinite(n) && n >= 0)
.sort((a, b) => a - b);
}
async function pingStaff(client, message, embedFields) {
const channelId = CONFIG.ALL_STAFF_CHANNEL_ID;
if (!channelId || !client) return;
try {
const channel = await client.channels.fetch(channelId);
if (!channel) return;
const embed = new EmbedBuilder()
.setTitle('Staff Alert')
.setDescription(message)
.setColor(0xFF4400)
.setTimestamp();
if (embedFields.length > 0) {
embed.addFields(embedFields.map(f => ({
name: f.name,
value: String(f.value).slice(0, 1024),
inline: f.inline ?? true
})));
}
const content = CONFIG.SURGE_ROLE_ID ? `<@&${CONFIG.SURGE_ROLE_ID}>` : undefined;
await enqueueSend(channel, { content, embeds: [embed] });
} catch (_) {}
}
async function checkTicketSurge(client) {
if (!isEnabled('surge_tickets')) return;
const key = 'surge:tickets';
const since = new Date(Date.now() - CONFIG.SURGE_TICKET_WINDOW_MINUTES * 60000);
const count = await Ticket.countDocuments({ createdAt: { $gte: since } });
if (count >= CONFIG.SURGE_TICKET_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tickets'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets created in the past ${CONFIG.SURGE_TICKET_WINDOW_MINUTES} minutes.`,
[{ name: 'Action needed', value: 'Check open tickets and claim.', inline: false }]
);
}
} else {
clearEscalating(key);
}
}
async function checkGameSurge(client) {
if (!isEnabled('surge_game')) return;
const key = 'surge:game';
const since = new Date(Date.now() - CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES * 60000);
const gameCounts = await Ticket.aggregate([
{ $match: { createdAt: { $gte: since }, game: { $ne: null } } },
{ $group: { _id: '$game', count: { $sum: 1 } } },
{ $match: { count: { $gte: CONFIG.SURGE_GAME_TICKET_COUNT } } },
{ $sort: { count: -1 } }
]);
if (gameCounts.length > 0) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_game'));
if (thresholdMs !== null) {
const fields = gameCounts.map(g => ({
name: g._id,
value: `${g.count} tickets in ${CONFIG.SURGE_GAME_TICKET_WINDOW_MINUTES} min`,
inline: true
}));
await pingStaff(client, 'Game ticket surge detected.', fields);
}
} else {
clearEscalating(key);
}
}
async function checkStaleSurge(client) {
if (!isEnabled('surge_stale')) return;
const key = 'surge:stale';
const cutoff = new Date(Date.now() - CONFIG.SURGE_STALE_HOURS * 3600000);
const count = await Ticket.countDocuments({
status: 'open',
lastActivity: { $lte: cutoff, $ne: null }
});
if (count >= CONFIG.SURGE_STALE_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_stale'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets have had no activity in the past ${CONFIG.SURGE_STALE_HOURS} hours.`,
[{ name: 'Action needed', value: 'Review and respond to stale tickets.', inline: false }]
);
}
} else {
clearEscalating(key);
}
}
async function checkNeedsResponseSurge(client) {
if (!isEnabled('surge_needs_response')) return;
const key = 'surge:needs_response';
const cutoff = new Date(Date.now() - CONFIG.SURGE_NEEDS_RESPONSE_HOURS * 3600000);
const count = await Ticket.countDocuments({
status: 'open',
lastMessageAuthorIsStaff: false,
lastActivity: { $lte: cutoff, $ne: null }
});
if (count >= CONFIG.SURGE_NEEDS_RESPONSE_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_needs_response'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets are waiting on a staff response for over ${CONFIG.SURGE_NEEDS_RESPONSE_HOURS} hour(s).`,
[]
);
}
} else {
clearEscalating(key);
}
}
async function checkUnclaimedSurge(client) {
if (!isEnabled('surge_unclaimed')) return;
const key = 'surge:unclaimed';
const cutoff = new Date(Date.now() - CONFIG.SURGE_UNCLAIMED_MINUTES * 60000);
const count = await Ticket.countDocuments({
status: 'open',
claimedBy: null,
createdAt: { $lte: cutoff, $ne: null }
});
if (count >= CONFIG.SURGE_UNCLAIMED_COUNT) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_unclaimed'));
if (thresholdMs !== null) {
await pingStaff(client,
`${count} tickets have been unclaimed for over ${CONFIG.SURGE_UNCLAIMED_MINUTES} minutes.`,
[]
);
}
} else {
clearEscalating(key);
}
}
async function checkTier3UnclaimedSurge(client) {
if (!isEnabled('surge_tier3_unclaimed')) return;
const key = 'surge:tier3_unclaimed';
const cutoff = new Date(Date.now() - CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES * 60000);
const tickets = await Ticket.find({
status: 'open',
escalationTier: 2,
claimedBy: null,
createdAt: { $lte: cutoff, $ne: null }
}).lean();
if (tickets.length > 0) {
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_tier3_unclaimed'));
if (thresholdMs !== null) {
await pingStaff(client,
`${tickets.length} Tier 3 ticket(s) unclaimed for over ${CONFIG.SURGE_TIER3_UNCLAIMED_MINUTES} minutes.`,
tickets.map(t => ({ name: t.subject || 'No subject', value: `<#${t.discordThreadId}>`, inline: true }))
);
}
} else {
clearEscalating(key);
}
}
async function checkZeroStaffSurge(client) {
if (!isEnabled('surge_no_staff')) return;
const key = 'surge:no_staff';
if (!CONFIG.STAFF_IDS.length) {
clearEscalating(key);
return;
}
const openCount = await Ticket.countDocuments({ status: 'open' });
if (openCount < CONFIG.SURGE_NO_STAFF_OPEN_TICKET_THRESHOLD) {
clearEscalating(key);
return;
}
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
if (!guild) {
clearEscalating(key);
return;
}
const { available, source } = isAnyStaffAvailable(guild);
let noStaff = false;
let detailLine = '';
const { online, dnd, offline } = getStaffAvailability(guild);
if (source === 'unknown') {
const recentlyActive = CONFIG.STAFF_IDS.filter(id => isStaffRecentlyActive(id, 60));
if (recentlyActive.length === 0) {
noStaff = true;
detailLine = 'No staff active in the last 60 minutes (presence intent unavailable, using message activity fallback).';
}
} else if (!available) {
noStaff = true;
const dndNote = dnd.length > 0 ? ` (${dnd.length} on DND)` : '';
detailLine = `${offline.length} staff offline/invisible${dndNote}. ${online.length} online.`;
}
if (!noStaff) {
clearEscalating(key);
return;
}
const thresholdMs = shouldFireCooldownEscalating(key, getThresholdsMs('surge_no_staff'));
if (thresholdMs === null) return;
const fields = [
{ name: 'Open tickets', value: String(openCount), inline: true },
{ name: 'Detection method', value: source === 'unknown' ? 'Message activity' : 'Presence', inline: true },
{ name: source === 'unknown' ? 'Note' : 'Staff status', value: detailLine, inline: false }
];
await pingStaff(client,
`${openCount} open ticket(s) with no staff available to respond.`,
fields
);
}
async function runSurgeChecks(client) {
try { await checkTicketSurge(client); } catch (e) { console.error('checkTicketSurge:', e); }
try { await checkGameSurge(client); } catch (e) { console.error('checkGameSurge:', e); }
try { await checkStaleSurge(client); } catch (e) { console.error('checkStaleSurge:', e); }
try { await checkNeedsResponseSurge(client); } catch (e) { console.error('checkNeedsResponseSurge:', e); }
try { await checkUnclaimedSurge(client); } catch (e) { console.error('checkUnclaimedSurge:', e); }
try { await checkTier3UnclaimedSurge(client); } catch (e) { console.error('checkTier3UnclaimedSurge:', e); }
try { await checkZeroStaffSurge(client); } catch (e) { console.error('checkZeroStaffSurge:', e); }
}
module.exports = { runSurgeChecks };

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; }

File diff suppressed because it is too large Load Diff

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

@@ -1,484 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Broccolini Settings</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div id="loading" class="loading"><div class="spinner"></div></div>
<div id="toast-container"></div>
<!-- Sidebar -->
<div class="sidebar-backdrop" id="sidebar-backdrop" aria-hidden="true"></div>
<nav class="sidebar" id="sidebar">
<div class="logo">Broccolini Settings</div>
<a href="/" class="active">Core</a>
<a href="/channels">Channels</a>
<a href="/categories">Categories</a>
<a href="/gmail">Gmail</a>
<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>
<a href="/staff">Staff</a>
<a href="/advanced">Advanced</a>
</nav>
<!-- Top bar -->
<div class="topbar">
<button type="button" class="menu-toggle" id="menu-toggle" aria-label="Toggle navigation" aria-expanded="false" aria-controls="sidebar">
<span class="menu-toggle-bars" aria-hidden="true"></span>
</button>
<h1>Settings</h1>
<div class="status">
<span class="dot" id="bot-status-dot"></span>
<span id="bot-status-text">Checking...</span>
</div>
<div class="actions">
<button type="button" id="logout-btn">Logout</button>
</div>
</div>
<!-- Main content -->
<div class="main">
<!-- 1. Core -->
<div class="section" id="s-core">
<div class="section-header"><h2>Core</h2><p>Discord bot credentials and guild</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Discord Token</label><input type="password" data-key="DISCORD_TOKEN" placeholder="Bot token"></div>
<div class="field"><label>Application ID</label><input type="text" data-key="DISCORD_APPLICATION_ID"></div>
<div class="field"><label>Guild ID</label><input type="text" data-key="DISCORD_GUILD_ID"></div>
</div></div>
</div>
<!-- 2. Channels -->
<div class="section" id="s-channels">
<div class="section-header"><h2>Channels</h2><p>Channel assignments for logging, transcripts, and alerts</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<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>
<!-- 3. Categories -->
<div class="section" id="s-categories">
<div class="section-header"><h2>Categories</h2><p>Ticket category assignments and escalation targets</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Email Ticket Category</label><input type="text" data-key="TICKET_CATEGORY_ID" data-smart="category"></div>
<div class="field"><label>Discord Ticket Category</label><input type="text" data-key="DISCORD_TICKET_CATEGORY_ID" data-smart="category"></div>
<div class="field"><label>Email T2 Category</label><input type="text" data-key="EMAIL_ESCALATED2_CHANNEL_ID" data-smart="category"></div>
<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>
<!-- 4. Gmail -->
<div class="section" id="s-gmail">
<div class="section-header"><h2>Gmail</h2><p>Google OAuth credentials and email settings</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Google Client ID</label><input type="text" data-key="GOOGLE_CLIENT_ID"></div>
<div class="field"><label>Google Client Secret</label><input type="password" data-key="GOOGLE_CLIENT_SECRET"></div>
<div class="field"><label>Refresh Token</label><input type="password" data-key="REFRESH_TOKEN"></div>
<div class="field"><label>Support Email</label><input type="email" data-key="MY_EMAIL"></div>
</div></div>
</div>
<!-- 5. Ticket Behavior -->
<div class="section" id="s-behavior">
<div class="section-header"><h2>Ticket Behavior</h2><p>Automation, limits, and messages</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Auto-Close</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="AUTO_CLOSE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Auto-Close 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>
<div class="field"><label>Global Ticket Limit</label><input type="number" data-key="GLOBAL_TICKET_LIMIT"></div>
<div class="field"><label>Rate Limit (per user)</label><input type="number" data-key="RATE_LIMIT_TICKETS_PER_USER"></div>
<div class="field"><label>Rate Limit Window (min)</label><input type="number" data-key="RATE_LIMIT_WINDOW_MINUTES"></div>
<div class="field"><label>Role to Ping</label><input type="text" data-key="ROLE_ID_TO_PING" data-smart="role"></div>
<div class="field full-width"><label>Welcome Message</label><textarea data-key="TICKET_WELCOME_MESSAGE" rows="3"></textarea></div>
<div class="field full-width"><label>Claimed Message</label><textarea data-key="TICKET_CLAIMED_MESSAGE" rows="2"></textarea><div class="hint">Variables: {staff_mention}, {staff_name}</div></div>
<div class="field full-width"><label>Unclaimed Message</label><textarea data-key="TICKET_UNCLAIMED_MESSAGE" rows="2"></textarea></div>
<div class="field full-width"><label>Escalation Message</label><textarea data-key="ESCALATION_MESSAGE" rows="3"></textarea><div class="hint">Variables: {support_name}</div></div>
<div class="field full-width"><label>Reminder Message</label><textarea data-key="REMINDER_MESSAGE" rows="2"></textarea><div class="hint">Variables: {ping}, {hours}</div></div>
</div></div>
</div>
<!-- 6. Staff Threads -->
<div class="section" id="s-threads">
<div class="section-header"><h2>Staff Threads</h2><p>Private staff discussion threads on ticket channels</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Enabled</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Thread Name</label><input type="text" data-key="STAFF_THREAD_NAME"></div>
<div class="field"><label>Auto-Add Role</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="STAFF_THREAD_AUTO_ADD_ROLE"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Staff Thread Role</label><input type="text" data-key="STAFF_THREAD_ROLE_ID" data-smart="role"></div>
</div></div>
</div>
<!-- 7. Pin Messages -->
<div class="section" id="s-pins">
<div class="section-header"><h2>Pin Messages</h2><p>Auto-pin welcome and escalation messages</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Pin Initial Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_INITIAL_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Pin Escalation Message</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_ESCALATION_MESSAGE_ENABLED"><span class="slider"></span></label><span>Enabled</span></div></div>
<div class="field"><label>Suppress Pin Notice</label><div class="toggle-wrap"><label class="toggle"><input type="checkbox" data-key="PIN_SUPPRESS_SYSTEM_MESSAGE"><span class="slider"></span></label><span>Enabled</span></div></div>
</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>
<div class="section-body"><div class="field-grid">
<div class="field full-width"><p class="logging-hint">Log channels are configured in the <a href="/channels">Channels</a> section. This section shows which logs are active based on configured channels.</p></div>
</div></div>
</div>
<!-- 11. Automation -->
<div class="section" id="s-automation">
<div class="section-header"><h2>Automation</h2><p>Polling intervals and timer durations</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Gmail Poll Interval (sec)</label><select data-key="GMAIL_POLL_INTERVAL_SECONDS">
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
<option value="300">5m</option><option value="600">10m</option>
</select></div>
<div class="field"><label>Force-Close Timer (sec)</label><select data-key="FORCE_CLOSE_TIMER_SECONDS">
<option value="5">5s</option><option value="10">10s</option><option value="30">30s</option><option value="45">45s</option>
<option value="60">1m</option><option value="120">2m</option><option value="180">3m</option><option value="240">4m</option>
<option value="300">5m</option><option value="600">10m</option>
</select></div>
</div></div>
</div>
<!-- 12. Appearance -->
<div class="section" id="s-appearance">
<div class="section-header"><h2>Appearance</h2><p>Embed colors, button labels, and emojis</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Open Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_OPEN"><span>Open tickets</span></div></div>
<div class="field"><label>Closed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLOSED"><span>Closed tickets</span></div></div>
<div class="field"><label>Claimed Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_CLAIMED"><span>Claimed tickets</span></div></div>
<div class="field"><label>Escalated Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_ESCALATED"><span>Escalated tickets</span></div></div>
<div class="field"><label>Info Color</label><div class="color-field"><input type="color" data-key="EMBED_COLOR_INFO"><span>Info embeds</span></div></div>
<div class="field"><label>Close Button Label</label><input type="text" data-key="BUTTON_LABEL_CLOSE"></div>
<div class="field"><label>Claim Button Label</label><input type="text" data-key="BUTTON_LABEL_CLAIM"></div>
<div class="field"><label>Unclaim Button Label</label><input type="text" data-key="BUTTON_LABEL_UNCLAIM"></div>
<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-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>
<!-- 14. Advanced -->
<div class="section" id="s-advanced">
<div class="section-header"><h2>Advanced</h2><p>Ports, URLs, game list, branding</p><span class="chevron">&#9660;</span></div>
<div class="section-body"><div class="field-grid">
<div class="field"><label>Bot Port</label><input type="number" data-key="DISCORD_ONLY_PORT"></div>
<div class="field"><label>Healthcheck Host</label><input type="text" data-key="HEALTHCHECK_HOST" placeholder="leave empty for all interfaces"></div>
<div class="field"><label>Settings Port</label><input type="number" data-key="SETTINGS_PORT"></div>
<div class="field"><label>Settings Domain</label><input type="text" data-key="SETTINGS_DOMAIN"></div>
<div class="field"><label>Internal API Port</label><input type="number" data-key="INTERNAL_API_PORT"></div>
<div class="field"><label>Support Name</label><input type="text" data-key="SUPPORT_NAME"></div>
<div class="field"><label>Logo URL</label><input type="text" data-key="LOGO_URL"></div>
<div class="field full-width"><label>Game List (comma-separated)</label><textarea data-key="GAME_LIST" rows="3"></textarea></div>
<div class="field full-width"><label>Email Signature (HTML, use \n for breaks)</label><textarea data-key="EMAIL_SIGNATURE" rows="3"></textarea></div>
<div class="field full-width"><label>Close Subject Prefix</label><input type="text" data-key="TICKET_CLOSE_SUBJECT_PREFIX"></div>
<div class="field full-width"><label>Close Message (email body)</label><textarea data-key="TICKET_CLOSE_MESSAGE" rows="2"></textarea></div>
<div class="field full-width"><label>Discord Close Message</label><textarea data-key="DISCORD_CLOSE_MESSAGE" rows="2"></textarea></div>
<div class="field full-width"><label>Transcript Message</label><textarea data-key="DISCORD_TRANSCRIPT_MESSAGE" rows="2"></textarea><div class="hint">Variables: {channel_name}, {email}, {date_opened}, {date_closed}</div></div>
<div class="field full-width"><label>Auto-Close Message</label><textarea data-key="DISCORD_AUTO_CLOSE_MESSAGE" rows="2"></textarea></div>
</div></div>
</div>
</div>
<!-- Save bar -->
<div id="save-bar" class="save-bar">
<span id="change-count">0 unsaved changes</span>
<div class="save-actions">
<button type="button" id="save-btn">Save</button>
<button type="button" id="save-restart-btn" class="danger">Save &amp; Restart Now</button>
<button type="button" id="schedule-restart-btn" class="secondary">Schedule restart...</button>
</div>
</div>
<!-- Schedule modal -->
<div id="schedule-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="schedule-modal-title">
<div class="modal-card">
<h3 id="schedule-modal-title">Schedule restart</h3>
<input type="datetime-local" id="schedule-datetime" aria-label="Restart date and time">
<div class="modal-actions">
<button type="button" id="schedule-confirm-btn">Schedule</button>
<button type="button" id="schedule-cancel-btn" class="secondary">Cancel</button>
</div>
</div>
</div>
<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>
</html>

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,162 +0,0 @@
(function () {
'use strict';
async function init() {
document.getElementById('loading').classList.remove('hidden');
try {
await Util.fetchCsrfToken();
const [config] = await Promise.all([
fetch('/api/config', { credentials: 'same-origin' }).then(r => r.json()),
DiscordFields.fetchGuildData()
]);
Fields.setSavedConfig(config);
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';
document.getElementById('bot-status-text').textContent = 'Unreachable';
}
document.getElementById('loading').classList.add('hidden');
setupSectionToggles();
Fields.setupSaveBar();
}
function setupSectionToggles() {
document.querySelectorAll('.section-header').forEach(header => {
header.addEventListener('click', () => {
header.closest('.section').classList.toggle('collapsed');
});
});
}
function openScheduleModal() {
const modal = document.getElementById('schedule-modal');
const dt = document.getElementById('schedule-datetime');
const min = Util.formatLocalDateTime(new Date(Date.now() + 60000));
dt.min = min;
dt.value = min;
Util.openModal(modal, { initialFocus: '#schedule-datetime' });
}
async function confirmScheduledRestart() {
const dt = document.getElementById('schedule-datetime').value;
if (!dt) return;
await fetch('/api/restart', {
method: 'POST',
credentials: 'same-origin',
headers: Util.csrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ mode: 'scheduled', scheduledFor: new Date(dt).toISOString() })
});
Util.closeModal(document.getElementById('schedule-modal'));
Util.showToast(`Restart scheduled for ${new Date(dt).toLocaleString()}`, 'warning');
}
async function doLogout() {
try {
await fetch('/logout', {
method: 'POST',
credentials: 'same-origin',
headers: Util.csrfHeaders()
});
} catch (e) { /* ignore */ }
window.location.href = '/login';
}
function setupActionButtons() {
document.getElementById('save-btn')?.addEventListener('click', () => Fields.saveConfig('save'));
document.getElementById('save-restart-btn')?.addEventListener('click', () => Fields.saveConfig('restart'));
document.getElementById('schedule-restart-btn')?.addEventListener('click', openScheduleModal);
document.getElementById('schedule-confirm-btn')?.addEventListener('click', confirmScheduledRestart);
document.getElementById('schedule-cancel-btn')?.addEventListener('click', () => {
Util.closeModal(document.getElementById('schedule-modal'));
});
document.getElementById('logout-btn')?.addEventListener('click', doLogout);
}
function setupMobileNav() {
const toggle = document.getElementById('menu-toggle');
const backdrop = document.getElementById('sidebar-backdrop');
toggle?.addEventListener('click', () => {
Util.setSidebarOpen(!document.body.classList.contains('sidebar-open'));
});
backdrop?.addEventListener('click', () => Util.setSidebarOpen(false));
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && document.body.classList.contains('sidebar-open')) {
Util.setSidebarOpen(false);
}
});
window.addEventListener('resize', () => {
if (!Util.isMobileViewport() && document.body.classList.contains('sidebar-open')) {
Util.setSidebarOpen(false);
}
});
}
let healthPollHandle = null;
function setBotStatus(online) {
const dot = document.getElementById('bot-status-dot');
const text = document.getElementById('bot-status-text');
if (!dot || !text) return;
dot.className = online ? 'dot online' : 'dot offline';
text.textContent = online ? 'Connected' : 'Unreachable';
}
async function pollHealth() {
try {
const res = await fetch('/healthz', { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
setBotStatus(Boolean(data.bot));
} else {
setBotStatus(false);
}
} catch (_) {
setBotStatus(false);
}
}
function scheduleNextHealthPoll() {
if (document.hidden) return;
healthPollHandle = setTimeout(async () => {
await pollHealth();
scheduleNextHealthPoll();
}, 20000);
}
function startHealthPolling() {
if (healthPollHandle) clearTimeout(healthPollHandle);
scheduleNextHealthPoll();
}
function stopHealthPolling() {
if (healthPollHandle) {
clearTimeout(healthPollHandle);
healthPollHandle = null;
}
}
function setupHealthPolling() {
document.addEventListener('visibilitychange', () => {
if (document.hidden) stopHealthPolling();
else startHealthPolling();
});
window.addEventListener('pagehide', stopHealthPolling);
startHealthPolling();
}
document.addEventListener('DOMContentLoaded', async () => {
Router.setupSidebarRouting();
setupActionButtons();
setupMobileNav();
await init();
Router.navigate(location.pathname, false);
setupHealthPolling();
});
window.App = { init };
})();

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

@@ -1,52 +0,0 @@
(function () {
'use strict';
const ROUTES = {
'/': 's-core',
'/channels': 's-channels',
'/categories': 's-categories',
'/gmail': 's-gmail',
'/behavior': 's-behavior',
'/threads': 's-threads',
'/pins': 's-pins',
'/notifications': 's-notifications',
'/logging': 's-logging',
'/automation': 's-automation',
'/appearance': 's-appearance',
'/staff': 's-staff',
'/advanced': 's-advanced'
};
function navigate(path, updateHistory = true) {
const sectionId = ROUTES[path] || ROUTES['/'];
const normalizedPath = ROUTES[path] ? path : '/';
if (updateHistory) history.pushState({}, '', normalizedPath);
document.querySelectorAll('.section').forEach(section => {
section.classList.toggle('hidden', section.id !== sectionId);
});
document.querySelectorAll('.sidebar a').forEach(link => {
link.classList.toggle('active', link.getAttribute('href') === normalizedPath);
});
}
function setupSidebarRouting() {
const sidebar = document.querySelector('.sidebar');
if (!sidebar) return;
sidebar.addEventListener('click', e => {
const a = e.target.closest('a');
if (!a) return;
e.preventDefault();
navigate(a.getAttribute('href'));
if (Util.isMobileViewport()) Util.setSidebarOpen(false);
});
window.addEventListener('popstate', () => {
navigate(location.pathname, false);
});
}
window.Router = { ROUTES, navigate, setupSidebarRouting };
})();

View File

@@ -1,4 +1,4 @@
require('dotenv').config({ path: process.env.ENV_FILE || '../.env' });
require('dotenv').config({ path: '../.env' });
const express = require('express');
const session = require('express-session');
const cookieParser = require('cookie-parser');
@@ -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'));

Some files were not shown because too many files have changed in this diff Show More