Compare commits

..

23 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
42 changed files with 4733 additions and 3807 deletions

View File

@@ -14,10 +14,8 @@ DISCORD_GUILD_ID= # Server (guild) ID where the bot runs
DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels DISCORD_TICKET_CATEGORY_ID= # Category for Discord-originated ticket channels
TICKET_CATEGORY_ID= # Category for email-originated ticket channels TICKET_CATEGORY_ID= # Category for email-originated ticket channels
# 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_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) # Escalation categories (tier 2 and tier 3)
DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord) DISCORD_ESCALATED_CATEGORY_ID= # Fallback escalation category (Discord)
@@ -32,7 +30,6 @@ ROLE_ID_TO_PING= # Role ID to ping on new tickets (config also
TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
DISCORD_CHANNEL_ID= # General Discord channel (if used)
# --- Discord: Ticket copy & buttons --- # --- Discord: Ticket copy & buttons ---
# ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME # ESCALATION_MESSAGE: use {support_name} for SUPPORT_NAME
@@ -50,8 +47,7 @@ GOOGLE_CLIENT_SECRET= # OAuth2 Client Secret
REFRESH_TOKEN= # OAuth2 refresh token for the support inbox REFRESH_TOKEN= # OAuth2 refresh token for the support inbox
MY_EMAIL= # Support inbox email address MY_EMAIL= # Support inbox email address
# --- Server & URLs --- # --- Server ---
NGROK_URL= # Public URL (optional); run ngrok outside this repo
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only 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) # HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
@@ -74,7 +70,6 @@ DISCORD_AUTO_CLOSE_MESSAGE= # Message in ticket when auto-closed (e.g. ...
# --- Ticket limits & permissions --- # --- Ticket limits & permissions ---
GLOBAL_TICKET_LIMIT=5 # Max concurrent open tickets globally 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_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 RATE_LIMIT_WINDOW_MINUTES=60 # Window in minutes for per-user rate limit
BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets BLACKLISTED_ROLES= # Comma-separated role IDs that cannot open tickets
@@ -83,7 +78,6 @@ ADDITIONAL_STAFF_ROLES= # Comma-separated role IDs with staff permissi
# --- Auto-close --- # --- Auto-close ---
AUTO_CLOSE_ENABLED=false AUTO_CLOSE_ENABLED=false
AUTO_CLOSE_AFTER_HOURS=72 AUTO_CLOSE_AFTER_HOURS=72
AUTO_CLOSE_MESSAGE= # Message when ticket is auto-closed
# --- Reminders --- # --- Reminders ---
REMINDER_ENABLED=false REMINDER_ENABLED=false
@@ -140,7 +134,6 @@ GAME_LIST=Project Zomboid, Minecraft, ...
# --- Embed colors (hex with 0x prefix) --- # --- Embed colors (hex with 0x prefix) ---
EMBED_COLOR_OPEN=0x00FF00 EMBED_COLOR_OPEN=0x00FF00
EMBED_COLOR_CLOSED=0xFF0000
EMBED_COLOR_CLAIMED=0xFFFF00 EMBED_COLOR_CLAIMED=0xFFFF00
EMBED_COLOR_ESCALATED=0xFF6600 EMBED_COLOR_ESCALATED=0xFF6600
EMBED_COLOR_INFO=0x1e2124 EMBED_COLOR_INFO=0x1e2124

2
.gitignore vendored
View File

@@ -1,8 +1,6 @@
# Dependencies # Dependencies
node_modules/ 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) # Environment / Secrets (keep .env.example and .env.test.example committed; never commit .env or .env.test)
.env .env
.env.* .env.*

View File

@@ -1,258 +0,0 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^W WRAP search if no match found.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-M_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k [_f_i_l_e] . --lesskey-file=[_f_i_l_e]
Use a lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n -N .... --line-numbers --LINE-NUMBERS
Don't use line numbers.
-o [_f_i_l_e] . --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] . --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p [_p_a_t_t_e_r_n] --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t [_t_a_g] .. --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--incsearch
Search file as each pattern character is typed in.
--line-num-width=N
Set the width of the -N line number field to N characters.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--rscroll=C
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--status-col-width=N
Set the width of the -J status column to N characters.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=N
Each click of the mouse wheel moves N lines.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.

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 # 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) For an architectural overview, see [HOWITWORKS.md](HOWITWORKS.md). For agent/contributor conventions, see [CLAUDE.md](CLAUDE.md).
---
## 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. |
--- ---
## Quick start ## Quick start
```bash ```bash
git clone <your-repo-url> git clone <repo-url>
cd broccolini-bot cd broccolini-bot
npm install npm install
cp .env.example .env 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). Need a Gmail refresh token? `node get-refresh-token.js` (redirect URI `http://localhost:3000/oauth2callback`). Probe Mongo with `npm run test-mongodb`.
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.
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: Host port `8892` → container `5000` (`DISCORD_ONLY_PORT`).
- **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).
---
## Configuration ## Configuration
Configuration is **environment variables** only, loaded in [`config.js`](config.js) as `CONFIG`. Names below match `.env.example` unless noted. 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:
### Discord (core) | Variable | Notes |
|----------|-------|
| Variable | Required | Description | | `DISCORD_TOKEN` / `DISCORD_BOT_TOKEN` | Bot token. First non-empty after trim wins. |
|----------|----------|-------------| | `DISCORD_APPLICATION_ID`, `DISCORD_GUILD_ID` | Required for slash command registration. |
| `DISCORD_TOKEN` | Yes* | Bot token (*or `DISCORD_BOT_TOKEN`, first non-empty wins after trim). | | `TICKET_CATEGORY_ID` | Default category for email tickets. Validated at startup. |
| `DISCORD_APPLICATION_ID` | Yes | Used as `CLIENT_ID` for REST command registration. | | `DISCORD_TICKET_CATEGORY_ID` | Category for Discord panel/context tickets (falls back to `TICKET_CATEGORY_ID`). |
| `DISCORD_GUILD_ID` | Yes | Guild where slash commands are registered. | | `ROLE_ID_TO_PING` | Support role pinged on new tickets. |
| `TICKET_CATEGORY_ID` | Yes | Default category for **email** tickets (startup validates this). | | `MONGODB_URI` | Mongo connection string. |
| `DISCORD_TICKET_CATEGORY_ID` | No | Category for **Discord** panel/context tickets (falls back to `TICKET_CATEGORY_ID`). | | `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `REFRESH_TOKEN` / `MY_EMAIL` | Gmail OAuth + canonical inbox address. |
| `EMAIL_THREAD_CHANNEL_ID` / `DISCORD_THREAD_CHANNEL_ID` | No | Parent **text** channels for thread-style tickets, when used. | | `RENAMER_BOT` | Optional secondary token used for channel renames. |
| `EMAIL_TICKET_OVERFLOW_CATEGORY_IDS` | No | Comma-separated extra categories when the main is full (50 channels). | | `INTERNAL_API_SECRET` / `INTERNAL_API_PORT` | Enable the internal config API used by the settings UI. |
| `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. | ## Slash commands
| `ADDITIONAL_STAFF_ROLES` | No | Extra role IDs treated as staff for commands. |
| `BLACKLISTED_ROLES` | No | Roles blocked from opening tickets. | | Command | Purpose |
| `TRANSCRIPT_CHANNEL_ID` | No | Where transcripts are posted on close. | |---------|---------|
| `LOGGING_CHANNEL_ID` | No | Ticket lifecycle logs. | | `/escalate`, `/deescalate` | Move ticket between tier 2/3 categories. |
| `DEBUGGING_CHANNEL_ID` | No | Optional error/debug forwarding. | | `/add`, `/remove` | Add/remove user from current ticket channel. |
| `BACKUP_EXPORT_CHANNEL_ID` | No | Target for `/backup` and `/export`. | | `/transfer` | Hand the claim to another staff member. |
| `ACCOUNT_INFO_CHANNEL_ID` | No | Account info flows. | | `/move` | Reparent the channel to another category. |
| `/force-close`, `/cancel-close`, `/closetimer` | Force-close flow with cancellable countdown. |
### Escalation categories | `/topic` | Set channel topic. |
| `/response` | Saved reply templates (`send`, `create`, `edit`, `delete`, `list`). |
| Variable | Description | | `/panel` | Post an "Open ticket" panel button (thread / category / both). |
|----------|-------------| | `/notifydm` | Toggle DM alerts when a customer replies in your claimed ticket. |
| `EMAIL_ESCALATED_CATEGORY_ID` | Legacy fallback; alias `ESCALATED_CATEGORY_ID`. | | `/signature` | Personal email signature (valediction, display name, tagline). |
| `DISCORD_ESCALATED_CATEGORY_ID` | Discord fallback tier-2style bucket. | | `/staffthread` | Toggle / configure staff-only threads on tickets. |
| `DISCORD_ESCALATED2_CHANNEL_ID` | Tier **2** placement for Discord tickets (or + fallback category). | | `/pinmessages` | Auto-pin welcome / escalation messages. |
| `EMAIL_ESCALATED2_CHANNEL_ID` | Tier **2** for email tickets (env name says CHANNEL for legacy reasons). | | `/gmailpoll` | Set the Gmail poll interval at runtime. |
| `DISCORD_ESCALATED3_CHANNEL_ID` | Tier **3** Discord. | | `/help` | In-bot summary. |
| `EMAIL_ESCALATED3_CHANNEL_ID` | Tier **3** email. |
Plus context menus: **Create Ticket From Message**, **View User Tickets**.
Slash `/escalate` and buttons require the appropriate tier IDs for **non-thread** channels (threads skip category moves).
## Settings UI (optional)
### Staff notifications, claimer display, admin
`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).
| 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.
---
## Troubleshooting ## Troubleshooting
| Symptom | Checks | | Symptom | Check |
|---------|--------| |---------|-------|
| **Commands missing** | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; **restart** bot; Discord can take time to sync. | | Slash commands missing | Correct `DISCORD_APPLICATION_ID` + `DISCORD_GUILD_ID`; restart; Discord can take a minute to sync. |
| **Gmail not ingesting** | `REFRESH_TOKEN`, API enablement, inbox auth; regenerate token if revoked. | | Gmail not ingesting | `REFRESH_TOKEN` valid? Auth failure halts polling — re-auth and restart. |
| **MongoDB errors** | `MONGODB_URI`, `npm run test-mongodb`. | | Mongo errors at startup | `MONGODB_URI` reachable? `npm run test-mongodb` to confirm. |
| **Channels not creating** | Bot **Manage Channels** in ticket categories; category not full (50) unless overflow set. | | Channel rename "too quickly" | Discord limit is 2 renames/10 min per channel — the queue serializes; wait it out. |
| **Modal / button no response** | Intents + permissions; bot online; check `DEBUGGING_CHANNEL_ID` / console. | | Modal/button no response | Bot online + intents enabled; check `DEBUGGING_CHANNEL_ID` / container logs. |
| **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/) |
---
## License ## 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, * Entry point initializes the Discord bot, wires event handlers,
* connects to MongoDB, starts Gmail polling, and runs the Express healthcheck. * 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 express = require('express');
const { connectMongoDB, closeMongoDB } = require('./db-connection'); const { connectMongoDB, closeMongoDB } = require('./db-connection');
const { CONFIG } = require('./config'); const { CONFIG } = require('./config');
@@ -11,21 +11,15 @@ const { mongoose } = require('./db-connection');
// Handlers // Handlers
const { handleButton, handleTicketModal } = require('./handlers/buttons'); const { handleButton, handleTicketModal } = require('./handlers/buttons');
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands'); const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
const { requireStaffRole } = require('./handlers/commands/helpers');
const { handleDiscordReply } = require('./handlers/messages'); const { handleDiscordReply } = require('./handlers/messages');
// Services & jobs // Services & jobs
const { sendTicketClosedEmail } = require('./services/gmail'); const { sendTicketClosedEmail } = require('./services/gmail');
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets'); const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
const { registerCommands } = require('./commands/register'); const { registerCommands } = require('./commands/register');
// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup.
const { setBot } = require('./api/botClient');
const { poll } = require('./gmail-poll'); const { poll } = require('./gmail-poll');
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog'); const { setClient: setDebugClient, logError } = 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');
let gmailPollInterval = null; let gmailPollInterval = null;
// Track all background setInterval handles so shutdown can clear them. // Track all background setInterval handles so shutdown can clear them.
@@ -93,7 +87,7 @@ const client = new Client({
// --- EVENT: interactionCreate --- // --- EVENT: interactionCreate ---
async function safeReplyError(interaction) { 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) { if (interaction.deferred || interaction.replied) {
await interaction.followUp(payload).catch(() => {}); await interaction.followUp(payload).catch(() => {});
} else { } else {
@@ -117,6 +111,9 @@ client.on('interactionCreate', async interaction => {
} }
if (interaction.isModalSubmit() && interaction.customId.startsWith('signature_modal_')) { 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 // Handle signature modal submit
try { try {
const valediction = interaction.fields.getTextInputValue('valediction'); const valediction = interaction.fields.getTextInputValue('valediction');
@@ -139,13 +136,13 @@ client.on('interactionCreate', async interaction => {
await interaction.reply({ await interaction.reply({
content: 'Signature settings saved successfully!', content: 'Signature settings saved successfully!',
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} catch (err) { } catch (err) {
console.error('Signature modal submit error:', err); console.error('Signature modal submit error:', err);
await interaction.reply({ await interaction.reply({
content: 'Failed to save signature settings.', content: 'Failed to save signature settings.',
ephemeral: true flags: MessageFlags.Ephemeral
}); });
} }
return; return;
@@ -172,6 +169,14 @@ client.on('messageCreate', async msg => {
await handleDiscordReply(msg); await handleDiscordReply(msg);
}); });
// HTTP server handles + readiness flag. Assigned inside the ready callback
// (httpServer, appReady) and the INTERNAL_API_SECRET branch below
// (internalServer); declared here so they're visible to the ready callback,
// the express middleware below, and the shutdown handler at the bottom.
let httpServer = null;
let internalServer = null;
let appReady = false;
client.once('ready', async () => { client.once('ready', async () => {
if (!process.env.MONGODB_URI) { if (!process.env.MONGODB_URI) {
console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.'); console.error('MONGODB_URI is not set in .env. Broccolini Bot requires MongoDB.');
@@ -179,7 +184,6 @@ client.once('ready', async () => {
} }
await connectMongoDB(process.env.MONGODB_URI); await connectMongoDB(process.env.MONGODB_URI);
setDebugClient(client); setDebugClient(client);
setBot(client);
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined; const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => { httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`); console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
@@ -229,18 +233,6 @@ client.once('ready', async () => {
console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)'); console.log('✓ Memory sweeps registered: every 6 hours (unref\'d)');
console.log('✓ Discord bot ready. Tag:', client.user.tag); console.log('✓ Discord bot ready. Tag:', client.user.tag);
logSystem('Bot online', [
{ name: 'Guild', value: guild ? `${guild.name} (${guild.id})` : 'N/A' },
{ name: 'Poll interval', value: `${CONFIG.GMAIL_POLL_INTERVAL_MS / 1000}s` },
{ name: 'Auto-close', value: CONFIG.AUTO_CLOSE_ENABLED ? `enabled (${CONFIG.AUTO_CLOSE_AFTER_HOURS}h)` : 'disabled' },
{ name: 'Auto-unclaim', value: CONFIG.AUTO_UNCLAIM_ENABLED ? `enabled (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS}h)` : 'disabled' },
{ name: 'Gmail log', value: CONFIG.GMAIL_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
{ name: 'Automation log', value: CONFIG.AUTOMATION_LOG_CHANNEL_ID ? 'configured' : 'not configured' },
{ name: 'Staff threads', value: CONFIG.STAFF_THREAD_ENABLED ? `enabled (name: "${CONFIG.STAFF_THREAD_NAME}")` : 'disabled' },
{ name: 'Pin initial message', value: CONFIG.PIN_INITIAL_MESSAGE_ENABLED ? 'enabled' : 'disabled' },
{ name: 'Pin escalation message', value: CONFIG.PIN_ESCALATION_MESSAGE_ENABLED ? 'enabled' : 'disabled' }
]).catch(() => {});
}); });
client.login(CONFIG.DISCORD_TOKEN); client.login(CONFIG.DISCORD_TOKEN);
@@ -248,7 +240,7 @@ client.login(CONFIG.DISCORD_TOKEN);
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
// Reject API traffic with 503 until ready event has fired and routes are mounted. // 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) => { app.use((req, res, next) => {
if (!appReady && req.path.startsWith('/api')) { if (!appReady && req.path.startsWith('/api')) {
return res.status(503).json({ error: 'Bot is starting; API not ready yet.' }); return res.status(503).json({ error: 'Bot is starting; API not ready yet.' });
@@ -263,8 +255,6 @@ const internalApi = require('./routes/internalApi');
const internalApp = express(); const internalApp = express();
internalApp.use('/internal', internalApi); internalApp.use('/internal', internalApi);
let httpServer = null;
let internalServer = null;
if (CONFIG.INTERNAL_API_SECRET) { if (CONFIG.INTERNAL_API_SECRET) {
// Must bind all-interfaces inside the bot container: the settings-site is a // 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 // separate container on broccoli-net and reaches this API over the docker
@@ -284,10 +274,7 @@ let shuttingDown = false;
async function handleShutdown(signal) { async function handleShutdown(signal) {
if (shuttingDown) return; if (shuttingDown) return;
shuttingDown = true; shuttingDown = true;
await Promise.race([ console.log(`Bot shutting down (${signal})`);
logSystem('Bot shutting down', [{ name: 'Signal', value: signal }]),
new Promise(r => setTimeout(r, 2000))
]);
for (const handle of activeIntervals) { for (const handle of activeIntervals) {
try { clearInterval(handle); } catch (_) {} try { clearInterval(handle); } catch (_) {}
} }
@@ -313,13 +300,5 @@ module.exports = {
client, client,
setGmailPollInterval, setGmailPollInterval,
clearGmailPollInterval, clearGmailPollInterval,
trackTimeout, trackTimeout
sendGmailReply,
sendTicketClosedEmail,
getNextTicketNumber,
getCleanBody,
detectGame,
stripEmailQuotes,
stripMobileFooter,
htmlToTextWithBlocks
}; };

View File

@@ -357,8 +357,6 @@ async function registerCommands() {
.setDescription('Poll interval') .setDescription('Poll interval')
.setRequired(true) .setRequired(true)
.addChoices( .addChoices(
{ name: '5s', value: '5' },
{ name: '10s', value: '10' },
{ name: '30s', value: '30' }, { name: '30s', value: '30' },
{ name: '45s', value: '45' }, { name: '45s', value: '45' },
{ name: '1m', value: '60' }, { name: '1m', value: '60' },

View File

@@ -1,25 +1,9 @@
/** /**
* Broccolini Bot configuration and game lists. * Broccolini Bot configuration and game lists.
* Load dotenv so env is available when this module is required first.
* dotenv-expand resolves ${NGROK_URL} etc. in .env.
* *
* Never commit .env; agents must not modify .env without explicit user confirmation. * Never commit .env; agents must not modify .env without explicit user confirmation.
*/ */
const path = require('path'); require('dotenv').config({ debug: process.env.NODE_ENV === 'development' });
const dotenv = require('dotenv');
const dotenvExpand = require('dotenv-expand');
const parsed = dotenv.config({ debug: process.env.NODE_ENV === 'development' });
dotenvExpand.expand(parsed);
// Also load repo-root .env; only non-empty values override (so empty DISCORD_BOT_TOKEN= in root does not wipe app .env)
const rootEnv = path.resolve(process.cwd(), '..', '.env');
const rootParsed = dotenv.config({ path: rootEnv });
if (!rootParsed.error && rootParsed.parsed) {
for (const [k, v] of Object.entries(rootParsed.parsed)) {
if (v != null && String(v).trim() !== '') process.env[k] = v;
}
dotenvExpand.expand(rootParsed);
}
function toInt(v, fallback) { function toInt(v, fallback) {
const n = parseInt(v, 10); const n = parseInt(v, 10);
@@ -31,23 +15,12 @@ const CONFIG = {
DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null, DISCORD_GUILD_ID: process.env.DISCORD_GUILD_ID || null,
TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID, TICKET_CATEGORY_ID: process.env.TICKET_CATEGORY_ID,
TICKET_CATEGORY_NAME: process.env.TICKET_CATEGORY_NAME || 'Open Tickets', 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_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_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, ROLE_TO_PING_ID: process.env.ROLE_ID_TO_PING || process.env.ROLE_TO_PING_ID,
TRANSCRIPT_CHAN: process.env.TRANSCRIPT_CHANNEL_ID, TRANSCRIPT_CHANNEL_ID: process.env.TRANSCRIPT_CHANNEL_ID,
LOG_CHAN: process.env.LOGGING_CHANNEL_ID, LOGGING_CHANNEL_ID: process.env.LOGGING_CHANNEL_ID,
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null, DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
CLIENT_ID: process.env.DISCORD_APPLICATION_ID, CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
REFRESH_TOKEN: process.env.REFRESH_TOKEN, REFRESH_TOKEN: process.env.REFRESH_TOKEN,
MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(), MY_EMAIL: (process.env.MY_EMAIL || '').toLowerCase(),
@@ -75,9 +48,7 @@ const CONFIG = {
DISCORD_AUTO_CLOSE_MESSAGE: process.env.DISCORD_AUTO_CLOSE_MESSAGE || 'This ticket was closed due to inactivity. If you still need assistance, please open a new ticket.', 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_ENABLED: process.env.AUTO_CLOSE_ENABLED === 'true',
AUTO_CLOSE_AFTER_HOURS: toInt(process.env.AUTO_CLOSE_AFTER_HOURS, 72), 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), 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_TICKETS_PER_USER: toInt(process.env.RATE_LIMIT_TICKETS_PER_USER, 0),
RATE_LIMIT_WINDOW_MINUTES: toInt(process.env.RATE_LIMIT_WINDOW_MINUTES, 60), 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), BLACKLISTED_ROLES: (process.env.BLACKLISTED_ROLES || '').split(',').map(r => r.trim()).filter(Boolean),
@@ -103,7 +74,6 @@ const CONFIG = {
BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌', BUTTON_EMOJI_CLAIM: process.env.BUTTON_EMOJI_CLAIM || '📌',
BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓', BUTTON_EMOJI_UNCLAIM: process.env.BUTTON_EMOJI_UNCLAIM || '🔓',
EMBED_COLOR_OPEN: toInt(process.env.EMBED_COLOR_OPEN, 0x00FF00), 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_CLAIMED: toInt(process.env.EMBED_COLOR_CLAIMED, 0xFFFF00),
EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600), EMBED_COLOR_ESCALATED: toInt(process.env.EMBED_COLOR_ESCALATED, 0xFF6600),
EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124), EMBED_COLOR_INFO: toInt(process.env.EMBED_COLOR_INFO, 0x1e2124),
@@ -142,42 +112,8 @@ const GAME_ALIASES = {
CS2: 'Counter-Strike 2' 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 = { module.exports = {
CONFIG, CONFIG,
GAME_NAMES, GAME_NAMES,
GAME_ALIASES, GAME_ALIASES
GAME_NAME_TO_KEY
}; };

View File

@@ -1,82 +0,0 @@
.claude/settings.local.json | 51 ---------
.env.example | 61 +----------
.env.test.example | 128 ----------------------
.gitignore | 7 +-
.gitlab-ci.yml | 19 ----
CLAUDE.md | 129 ----------------------
FEATURES.md | 87 ---------------
api/{bosscordClient.js => botClient.js} | 0
broccolini-discord.js | 80 +-------------
broccolini_bot_context.md | 512 ---------------------------------------------------------------------------------------
commands/register.js | 203 +----------------------------------
config.js | 193 +++------------------------------
docs/CRITICAL_FILES_AND_HOW_IT_WORKS.md | 250 -------------------------------------------
docs/README.md | 50 ---------
docs/api/DISCORD_API_IMPROVEMENTS.md | 665 -----------------------------------------------------------------------------------------------------------------
docs/api/DISCORD_API_VALIDATION.md | 570 -------------------------------------------------------------------------------------------------
docs/architecture/COMMANDS_ANALYSIS.md | 83 ---------------
docs/architecture/CRITICAL_FILES_AND_HOW_IT_WORKS.md | 250 -------------------------------------------
docs/features/FEATURES_SUMMARY.md | 331 ---------------------------------------------------------
docs/features/IMPLEMENTATION_SUMMARY.md | 454 -----------------------------------------------------------------------------
docs/features/NEW_FEATURES.md | 365 --------------------------------------------------------------
docs/features/PHASE_FEATURES.md | 531 -------------------------------------------------------------------------------------------
docs/features/PROPOSAL.md | 72 -------------
docs/features/UPGRADE_COMPLETE.md | 352 ------------------------------------------------------------
docs/reference/Untitled | 13 ---
docs/reference/game-list.md | 97 -----------------
docs/reference/regex-and-games.md | 60 -----------
docs/setup/1PASSWORD.md | 126 ----------------------
docs/setup/ENV_AND_SECURITY.md | 73 -------------
docs/setup/MONGODB_SETUP.md | 166 -----------------------------
docs/setup/PROJECT_STRUCTURE.md | 154 ---------------------------
docs/setup/QUICKSTART.md | 199 ----------------------------------
game-options.json | 32 ------
git | 0
gmail-poll.js | 57 ++++------
handlers/accountinfo.js | 195 ----------------------------------
handlers/analytics.js | 89 ----------------
handlers/buttons.js | 143 +++++++------------------
handlers/commands.js | 519 ++++++++---------------------------------------------------------------------------------
handlers/messages.js | 47 +++-----
handlers/messages.js.bak3-20260421 | 106 ++++++++++++++++++
handlers/setup.js | 656 ----------------------------------------------------------------------------------------------------------------
models.js | 811 ------------------------------------------------------------------------------------------------------------------------------------------
package.json | 6 +-
routes/bosscord.js | 239 -----------------------------------------
routes/internalApi.js | 70 +-----------
scripts/bulk-lookup-users-v2.js | 193 ---------------------------------
scripts/bulk-lookup-users.js | 174 ------------------------------
scripts/export-transcript-embeds.js | 109 -------------------
scripts/fetch-channel-messages.js | 57 ----------
scripts/fetch-channel.js | 51 ---------
scripts/fetch-message.js | 71 -------------
scripts/find-transcript-by-member.js | 80 --------------
scripts/find-transcript-by-owner.js | 92 ----------------
scripts/lookup-user.js | 39 -------
scripts/lookup-with-dedicated-bot.js | 183 --------------------------------
scripts/lookup-with-roles.js | 237 -----------------------------------------
scripts/map-batch-to-transcript.js | 129 ----------------------
scripts/test-mongodb.js | 7 +-
services/chatAlertChecker.js | 98 -----------------
services/configPersistence.js | 4 +-
services/configSchema.js | 37 +------
services/gmail.js | 54 ++++------
services/gmail.js.bak3-20260421 | 346 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
services/guildSettings.js | 33 ------
services/notificationEnabled.js | 102 ------------------
services/notificationRegistry.js | 214 -------------------------------------
services/patternChecker.js | 587 ----------------------------------------------------------------------------------------------------
services/patternStore.js | 286 -------------------------------------------------
services/staffChannel.js | 89 ----------------
services/staffNotifications.js | 149 --------------------------
services/staffPresence.js | 48 ---------
services/surgeChecker.js | 260 ---------------------------------------------
services/tickets.js | 176 +++++++-----------------------
settings-site/CLAUDE.md | 76 -------------
settings-site/public/css/main.css.bak-20260421 | 1026 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
settings-site/public/index.html.bak-20260421 | 484 -----------------------------------------------------------------------------------
settings-site/public/js/app.js.bak-20260421 | 162 ----------------------------
settings-site/public/js/router.js.bak-20260421 | 52 ---------
settings-site/server.js | 2 +-
utils.js | 10 +-
81 files changed, 670 insertions(+), 14348 deletions(-)

View File

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

View File

@@ -1,25 +1,28 @@
/** /**
* Gmail polling fetches unread emails and creates/updates Discord ticket channels. * 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 { const {
ChannelType, ChannelType,
EmbedBuilder,
EmbedBuilder PermissionFlagsBits
} = require('discord.js'); } = require('discord.js');
const { mongoose, withRetry } = require('./db-connection'); const { mongoose, withRetry } = require('./db-connection');
const { CONFIG, GAME_NAME_TO_KEY } = require('./config'); const { CONFIG } = require('./config');
const { const {
getCleanBody, getCleanBody,
extractRawEmail, extractRawEmail,
stripEmailQuotes, stripEmailQuotes,
stripMobileFooter, stripMobileFooter,
detectGame, detectGame,
enforceEmbedLimit,
sanitizeEmbedText sanitizeEmbedText
} = require('./utils'); } = require('./utils');
const { getGmailClient } = require('./services/gmail'); const { getGmailClient } = require('./services/gmail');
const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets'); const { getNextTicketNumber, checkTicketLimits, getOrCreateTicketCategory, toDiscordSafeName, getSenderLocal } = require('./services/tickets');
const { logError, logGmail, logAutomation } = require('./services/debugLog'); const { logError } = require('./services/debugLog');
const { enqueueSend } = require('./services/channelQueue'); const { enqueueSend } = require('./services/channelQueue');
const { getTicketActionRow } = require('./utils/ticketComponents'); const { getTicketActionRow } = require('./utils/ticketComponents');
@@ -29,7 +32,6 @@ const Transcript = mongoose.model('Transcript');
let isPolling = false; let isPolling = false;
let authErrorNotified = false; let authErrorNotified = false;
let pollSuspended = false; let pollSuspended = false;
let pollCount = 0, totalProcessed = 0, totalSkipped = 0, totalErrors = 0;
function setPollSuspended(val) { function setPollSuspended(val) {
pollSuspended = !!val; pollSuspended = !!val;
@@ -37,6 +39,228 @@ function setPollSuspended(val) {
} }
function isPollSuspended() { return pollSuspended; } 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. * Poll Gmail for unread primary-inbox messages and route them to Discord.
* @param {import('discord.js').Client} client * @param {import('discord.js').Client} client
@@ -45,324 +269,137 @@ async function poll(client) {
if (isPolling || pollSuspended) return; if (isPolling || pollSuspended) return;
isPolling = true; isPolling = true;
try { 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()...'); console.log('Running poll()...');
try { try {
const gmail = getGmailClient(); const gmail = getGmailClient();
const list = await gmail.users.messages.list({ 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({
userId: 'me', userId: 'me',
id: msgRef.id q: 'is:unread category:primary'
}); });
if (!list.data.messages) return;
const from = const guild = locateGuild(client);
email.data.payload.headers.find(h => h.name === 'From') if (!guild) return;
?.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 subject = for (const msgRef of list.data.messages) {
email.data.payload.headers.find(h => h.name === 'Subject') const email = await gmail.users.messages.get({ userId: 'me', id: msgRef.id });
?.value || 'New Ticket'; const parsed = parseGmailMessage(email);
const rawBody = getCleanBody(email.data.payload);
const sEmail = extractRawEmail(from).toLowerCase(); if (parsed.isSelf) {
const sName = await markGmailMessageRead(gmail, msgRef);
(from.match(/^(.*?)\s*<.*>$/) || [null, from])[1] continue;
?.replace(/"/g, '') }
.trim() || 'Unknown';
const hasReplyHeaderFrom = const existing = await Ticket.findOne({ gmailThreadId: parsed.threadId })
/(?:\n{2,}|(?:^|\n)--\s*\n)[^\S\r\n]*From:\s.*<.*@.*>/i.test(rawBody); .select('gmailThreadId discordThreadId status')
const looksLikeReply = .lean();
/\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();
let ticketChan = null; let ticketChan = null;
let parentCategoryIdForTicket = null; let parentCategoryIdForTicket = null;
let isReopened = false; let isReopened = false;
if (existing && existing.discordThreadId) { if (existing && existing.discordThreadId) {
ticketChan = await guild.channels ticketChan = await guild.channels.fetch(existing.discordThreadId).catch(() => null);
.fetch(existing.discordThreadId) } else if (existing && existing.status === 'closed') {
.catch(() => null); isReopened = true;
} else if (existing && existing.status === 'closed') { }
isReopened = true;
}
if (ticketChan) { if (ticketChan) {
const truncatedFollowup = followupBody.slice(0, 1800); // Append follow-up to existing channel.
// Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions. const truncatedFollowup = parsed.followupBody.slice(0, 1800);
await enqueueSend( // Role ping is intentional; body is attacker-controlled email content — suppress user/everyone mentions.
ticketChan, await enqueueSend(ticketChan, {
{ content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${parsed.senderEmail}:**\n${truncatedFollowup}`,
content: `<@&${CONFIG.ROLE_ID_TO_PING}>\n**New Follow-up from ${sEmail}:**\n${truncatedFollowup}`,
allowedMentions: { parse: ['roles'] } allowedMentions: { parse: ['roles'] }
});
} 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;
} }
);
} 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;
}
const { number } = await getNextTicketNumber(sEmail); const { number } = await getNextTicketNumber(parsed.senderEmail);
const creatorNickname = getSenderLocal(sEmail); const created = await findOrCreateTicketChannel(guild, parsed, number);
const chanName = toDiscordSafeName(`unclaimed-${creatorNickname}-${number}`); if (!created) {
await markGmailMessageRead(gmail, msgRef);
try { continue;
const parentId = await getOrCreateTicketCategory(
guild,
CONFIG.TICKET_CATEGORY_ID,
CONFIG.TICKET_CATEGORY_NAME
);
parentCategoryIdForTicket = parentId;
try {
ticketChan = await guild.channels.create({
name: chanName,
type: ChannelType.GuildText,
parent: parentId
});
} catch (createErr) {
console.error('Channel create error (email ticket):', createErr);
throw createErr;
} }
} catch (err) { ticketChan = created.channel;
console.error('Channel create error (payload):', { parentCategoryIdForTicket = created.parentCategoryId;
message: err.message,
code: err.code, const detectedGame = detectGame(parsed.subject, parsed.rawBody);
rawError: err.rawError 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 }
);
const welcomeMsg = await enqueueSend(ticketChan, {
content: `<@&${CONFIG.ROLE_ID_TO_PING}>`,
embeds: [ticketInfoEmbed],
components: [buttons],
allowedMentions: { parse: ['roles'] }
}); });
await gmail.users.messages.batchModify({
userId: 'me', const { createStaffThread } = require('./services/staffThread');
requestBody: { await createStaffThread(ticketChan, client).catch(() => {});
ids: [msgRef.id],
removeLabelIds: ['UNREAD', 'INBOX'] if (CONFIG.PIN_INITIAL_MESSAGE_ENABLED && welcomeMsg) {
} const { pinMessage } = require('./services/pinMessage');
await pinMessage(welcomeMsg, client).catch(() => {});
}
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: [] }
}); });
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 = const now = new Date();
detectedGame && detectedGame !== 'Not Mentioned' const defaultPriority = CONFIG.PRIORITY_ENABLED ? CONFIG.DEFAULT_PRIORITY : 'normal';
? GAME_NAME_TO_KEY[detectedGame] || null await withRetry(() => Ticket.findOneAndUpdate(
: null; { gmailThreadId: parsed.threadId },
{
const buttons = getTicketActionRow({ escalationTier: 0 }); $set: {
discordThreadId: ticketChan.id,
const ticketInfoEmbed = new EmbedBuilder() senderEmail: parsed.senderEmail,
.setColor(CONFIG.EMBED_COLOR_INFO) subject: parsed.subject,
.addFields( createdAt: now,
{ name: 'Name', value: `\`\`\`\n${sanitizeEmbedText(sName)}\n\`\`\``, inline: false }, status: 'open',
{ name: 'Email', value: `\`\`\`\n${sanitizeEmbedText(sEmail)}\n\`\`\``, inline: false }, ticketNumber: number,
{ name: 'Game', value: `\`\`\`\n${sanitizeEmbedText(detectedGame)}\n\`\`\``, inline: false }, priority: defaultPriority,
{ name: 'Subject', value: `\`\`\`\n${sanitizeEmbedText(subject) || 'No subject'}\n\`\`\``, inline: false } lastActivity: now,
); parentCategoryId: parentCategoryIdForTicket
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]
});
}
}
} }
} },
} catch (err) { { upsert: true, new: true }
console.error('Error linking previous transcripts:', err); ));
}
} }
const truncated = firstBody.slice(0, 1900); console.log('Archiving/reading Gmail message', msgRef.id);
// Email body is attacker-controlled — no mentions may fire from its content. await markGmailMessageRead(gmail, msgRef);
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(() => {});
} }
console.log('Archiving/reading Gmail message', msgRef.id); authErrorNotified = false;
await gmail.users.messages.batchModify({ } catch (e) {
userId: 'me', oauthSuspendIfPermanent(e, client);
requestBody: { console.error('POLL ERROR:', e);
ids: [msgRef.id], logError('Gmail poll', e, null, client).catch(() => {});
removeLabelIds: ['UNREAD', 'INBOX']
}
});
} }
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 { } finally {
isPolling = false; isPolling = false;
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,128 @@
/**
* Force-close flow: /force-close, /cancel-close, /closetimer, plus the
* countdown-elapses finalize step and transcript renderer that the
* countdown's setTimeout calls back into.
*
* Note: the button-driven close path lives in handlers/buttons.js
* (handleCloseButton / handleConfirmCloseRequest / runFinalClose).
* This module covers the slash-command-driven path only.
*/
const { AttachmentBuilder, MessageFlags } = require('discord.js');
const { mongoose } = require('../../db-connection');
const { CONFIG } = require('../../config');
const { enqueueSend } = require('../../services/channelQueue');
const { logTicketEvent } = require('../../services/debugLog');
const { pendingCloses } = require('../pendingCloses');
const { findTicketForChannel } = require('../sharedHelpers');
const Ticket = mongoose.model('Ticket');
async function handleCloseTimer(interaction) {
const seconds = parseInt(interaction.options.getString('seconds'), 10);
CONFIG.FORCE_CLOSE_TIMER = seconds;
logTicketEvent('Close timer updated', [
{ name: 'Duration', value: `${seconds}s` },
{ name: 'Set by', value: interaction.user.tag }
], interaction).catch(() => {});
return interaction.reply({ content: `Force-close timer set to ${seconds} seconds.`, flags: MessageFlags.Ephemeral });
}
async function handleCancelClose(interaction) {
const pending = pendingCloses.get(interaction.channel.id);
if (!pending) {
return interaction.reply({ content: 'No pending close for this channel.', flags: MessageFlags.Ephemeral });
}
clearTimeout(pending.timeout);
logTicketEvent('Force-close cancelled', [
{ name: 'Ticket', value: interaction.channel.name || interaction.channel.id },
{ name: 'Cancelled by', value: interaction.user.tag },
{ name: 'Original setter', value: pending.username || 'Unknown' }
], interaction).catch(() => {});
pendingCloses.delete(interaction.channel.id);
return interaction.reply({ content: 'Close cancelled.', flags: MessageFlags.Ephemeral });
}
async function handleForceClose(interaction) {
const ticket = await findTicketForChannel(interaction);
if (!ticket) return;
if (pendingCloses.has(interaction.channel.id)) {
return interaction.reply({ content: 'A close is already pending for this ticket.', flags: MessageFlags.Ephemeral });
}
const timerSeconds = CONFIG.FORCE_CLOSE_TIMER;
await interaction.reply(`Closing ticket in ${timerSeconds} seconds. Use \`/cancel-close\` to abort.`);
const channelRef = interaction.channel;
const clientRef = interaction.client;
const timerId = setTimeout(() => finalizeForceClose(channelRef, clientRef), timerSeconds * 1000);
pendingCloses.set(channelRef.id, { timeout: timerId, userId: interaction.user.id, username: interaction.user.tag });
}
/** Performs the actual force-close work after the countdown elapses. */
async function finalizeForceClose(channelRef, clientRef) {
pendingCloses.delete(channelRef.id);
const freshTicket = await Ticket.findOne({ discordThreadId: channelRef.id }).lean();
if (!freshTicket || freshTicket.status === 'closed') return;
try {
// $unset welcomeMessageId so a future reopen on this thread doesn't carry
// a stale message ID pointing into the now-deleted channel.
await Ticket.updateOne(
{ gmailThreadId: freshTicket.gmailThreadId },
{ $set: { status: 'closed' }, $unset: { welcomeMessageId: '' } }
);
await enqueueSend(channelRef, 'Ticket force-closed. Archiving...');
await postTranscript(channelRef, clientRef, freshTicket).catch(tErr =>
console.error('Transcript error (force-close):', tErr)
);
setTimeout(() => {
channelRef.delete('Ticket force-closed').catch(e =>
console.error('Failed to delete channel:', e)
);
}, 5000);
} catch (err) {
console.error('Force close error:', err);
}
}
/** Render and post a closing transcript for a ticket. */
async function postTranscript(channelRef, clientRef, freshTicket) {
await enqueueSend(channelRef, CONFIG.DISCORD_CLOSE_MESSAGE);
const messages = await channelRef.messages.fetch({ limit: 100 });
const log =
`TRANSCRIPT: ${freshTicket.subject}\nUser: ${freshTicket.senderEmail}\n---\n` +
messages
.reverse()
.map(m => `[${m.createdAt.toLocaleString()}] ${m.author.tag}: ${m.cleanContent}`)
.join('\n');
const file = new AttachmentBuilder(Buffer.from(log), {
name: `transcript-${channelRef.name}.txt`
});
const transcriptChan = await clientRef.channels
.fetch(CONFIG.TRANSCRIPT_CHANNEL_ID)
.catch(() => null);
if (!transcriptChan) return;
const fmt = (d) => new Date(d).toLocaleString('en-US', {
month: '2-digit', day: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: true, timeZoneName: 'short'
});
const openedStr = fmt(freshTicket.createdAt);
const closedStr = fmt(new Date());
const transcriptContent = CONFIG.DISCORD_TRANSCRIPT_MESSAGE
.replace(/\{channel_name\}/g, channelRef.name)
.replace(/\{email\}/g, freshTicket.senderEmail || '')
.replace(/\{date_opened\}/g, openedStr)
.replace(/\{date_closed\}/g, closedStr)
+ `\n\nDate Opened: ${openedStr}\nDate Closed: ${closedStr}`;
await enqueueSend(transcriptChan, { content: transcriptContent, files: [file] });
}
module.exports = { handleCloseTimer, handleCancelClose, handleForceClose };

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

54
handlers/sharedHelpers.js Normal file
View File

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

View File

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

1596
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -4,7 +4,6 @@ const { ChannelType } = require('discord.js');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { safeEqual } = require('../utils'); const { safeEqual } = require('../utils');
const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence'); const { applyConfigUpdates, readAllConfig } = require('../services/configPersistence');
const { logSystem } = require('../services/debugLog');
const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema'); const { ALLOWED_CONFIG_KEYS } = require('../services/configSchema');
const router = express.Router(); const router = express.Router();
@@ -20,6 +19,17 @@ const internalLimiter = rateLimit({
message: { error: 'Too many requests, please try again later.' } 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); router.use(internalLimiter);
// Middleware: verify internal secret // Middleware: verify internal secret
@@ -54,11 +64,6 @@ router.post('/config', express.json(), async (req, res) => {
return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` }); return res.status(400).json({ error: `Disallowed config keys: ${rejected.join(', ')}` });
} }
const result = applyConfigUpdates(updates); 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. // 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). // Only 400 when every submitted key failed validation (i.e. the save did nothing).
const totalSubmitted = Object.keys(updates).length; const totalSubmitted = Object.keys(updates).length;
@@ -69,7 +74,7 @@ router.post('/config', express.json(), async (req, res) => {
// GET /discord/guild — return guild info for smart dropdowns // GET /discord/guild — return guild info for smart dropdowns
router.get('/discord/guild', async (req, res) => { router.get('/discord/guild', async (req, res) => {
try { try {
const client = require('../api/botClient').getBot(); const client = require('../broccolini-discord').client;
if (!client) return res.status(503).json({ error: 'Bot not ready' }); if (!client) return res.status(503).json({ error: 'Bot not ready' });
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID); const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
@@ -117,7 +122,7 @@ router.get('/discord/guild', async (req, res) => {
// POST /restart — restart the bot process // POST /restart — restart the bot process
let scheduledRestart = null; let scheduledRestart = null;
router.post('/restart', express.json(), (req, res) => { router.post('/restart', restartLimiter, express.json(), (req, res) => {
const { mode, scheduledFor } = req.body; const { mode, scheduledFor } = req.body;
if (mode === 'immediate') { if (mode === 'immediate') {
@@ -178,9 +183,6 @@ router.post('/gmail/reload', express.json(), async (req, res) => {
if (parent.setGmailPollInterval) { if (parent.setGmailPollInterval) {
parent.setGmailPollInterval(CONFIG.GMAIL_POLL_INTERVAL_MS); 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 }); res.json({ ok: true, email: emailAddress });
} catch (err) { } catch (err) {
const oauthError = err && err.response && err.response.data && err.response.data.error; const oauthError = err && err.response && err.response.data && err.response.data.error;

View File

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

View File

@@ -25,10 +25,13 @@ async function executeRename(channel, entry) {
// (403), or no token configured — fall back to the primary Discord.js client. // (403), or no token configured — fall back to the primary Discord.js client.
// Non-fallback errors rethrow so enqueueRename's catch can classify/log. // Non-fallback errors rethrow so enqueueRename's catch can classify/log.
if (err && err.fallback === true && channel && typeof channel.setName === 'function') { if (err && err.fallback === true && channel && typeof channel.setName === 'function') {
logWarn( // Local log only; discord.js's REST client transparently handles 429s
'renameQueue', // on the primary fallback, so this used to post a paired warning to
`secondary-bot ${err.status ?? 'unavailable'}; falling back to primary channel=${channel.id}` // the debug channel for every secondary-bot quota event with no
).catch(() => {}); // 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); await channel.setName(currentName);
} else { } else {
throw err; throw err;
@@ -80,6 +83,12 @@ function enqueueRename(channel, newName) {
// Shares renameChains so a move+rename pair on the same channel executes in // Shares renameChains so a move+rename pair on the same channel executes in
// call order. No coalescing: every move is a distinct chain link. // 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) { function enqueueMove(channel, categoryId) {
let entry = renameChains.get(channel.id); let entry = renameChains.get(channel.id);
if (!entry) { if (!entry) {
@@ -87,7 +96,7 @@ function enqueueMove(channel, categoryId) {
renameChains.set(channel.id, entry); 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; entry.chain = next;
next.catch((err) => { next.catch((err) => {
@@ -113,6 +122,81 @@ function enqueueMove(channel, categoryId) {
return next; 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. // Per-channel promise chain for send ordering and to prevent interleaving.
const sendChains = new Map(); const sendChains = new Map();
@@ -157,4 +241,4 @@ function enqueueDelete(channel) {
return next; return next;
} }
module.exports = { enqueueRename, enqueueMove, enqueueSend, enqueueDelete }; module.exports = { enqueueRename, enqueueMove, enqueueOverwrite, enqueueTopic, enqueueSend, enqueueDelete };

View File

@@ -150,7 +150,16 @@ function writeEnvFile(updates) {
const roundtrip = readEnvFile(); const roundtrip = readEnvFile();
if (roundtrip.size !== expected) { 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,8 +19,7 @@
const ALLOWED_CONFIG_KEYS = new Set([ const ALLOWED_CONFIG_KEYS = new Set([
// Ticket settings // Ticket settings
'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'TICKET_T2_CATEGORY_NAME', 'TICKET_T3_CATEGORY_NAME', 'TICKET_CATEGORY_ID', 'TICKET_CATEGORY_NAME', 'DISCORD_TICKET_CATEGORY_ID',
'EMAIL_TICKET_OVERFLOW_CATEGORY_IDS', 'DISCORD_TICKET_CATEGORY_ID', 'DISCORD_TICKET_OVERFLOW_CATEGORY_IDS',
// Escalation categories // Escalation categories
'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID', 'EMAIL_ESCALATED2_CHANNEL_ID', 'DISCORD_ESCALATED2_CHANNEL_ID',
'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID', 'EMAIL_ESCALATED3_CHANNEL_ID', 'DISCORD_ESCALATED3_CHANNEL_ID',
@@ -29,12 +28,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'ADMIN_ID', 'ADMIN_ID',
// Channel IDs // Channel IDs
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID', 'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
'DISCORD_CHANNEL_ID',
'RENAME_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
// Messages and labels // Messages and labels
'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE', 'ESCALATION_MESSAGE', 'TICKET_CLOSE_SUBJECT_PREFIX', 'TICKET_CLOSE_MESSAGE', 'TICKET_CLOSE_SIGNATURE',
'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE', 'DISCORD_CLOSE_MESSAGE', 'DISCORD_TRANSCRIPT_MESSAGE', 'DISCORD_AUTO_CLOSE_MESSAGE',
'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', 'REMINDER_MESSAGE', 'BUTTON_LABEL_CLOSE', 'BUTTON_LABEL_CLAIM', 'BUTTON_LABEL_UNCLAIM',
'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM', 'BUTTON_EMOJI_CLOSE', 'BUTTON_EMOJI_CLAIM', 'BUTTON_EMOJI_UNCLAIM',
// Branding // Branding
@@ -46,11 +44,11 @@ const ALLOWED_CONFIG_KEYS = new Set([
'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID', 'STAFF_THREAD_ENABLED', 'STAFF_THREAD_NAME', 'STAFF_THREAD_AUTO_ADD_ROLE', 'STAFF_THREAD_ROLE_ID',
'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE', 'PIN_INITIAL_MESSAGE_ENABLED', 'PIN_ESCALATION_MESSAGE_ENABLED', 'PIN_SUPPRESS_SYSTEM_MESSAGE',
// Limits and thresholds // Limits and thresholds
'GLOBAL_TICKET_LIMIT', 'TICKET_LIMIT_PER_CATEGORY', 'GLOBAL_TICKET_LIMIT',
'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES', 'RATE_LIMIT_TICKETS_PER_USER', 'RATE_LIMIT_WINDOW_MINUTES',
'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS', 'FORCE_CLOSE_TIMER_SECONDS', 'GMAIL_POLL_INTERVAL_SECONDS',
// Embed colors // Embed colors
'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLOSED', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO', 'EMBED_COLOR_OPEN', 'EMBED_COLOR_CLAIMED', 'EMBED_COLOR_ESCALATED', 'EMBED_COLOR_INFO',
'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI' 'PRIORITY_HIGH_EMOJI', 'PRIORITY_MEDIUM_EMOJI', 'PRIORITY_LOW_EMOJI'
]); ]);
@@ -201,32 +199,7 @@ function getValidator(key) {
return VALIDATORS[inferType(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 = { module.exports = {
ALLOWED_CONFIG_KEYS, ALLOWED_CONFIG_KEYS,
VALIDATORS, getValidator
ALL_VALIDATORS,
getValidator,
inferType
}; };

View File

@@ -11,6 +11,24 @@ function setClient(c) {
client = 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 --- // --- Helpers ---
async function sendToChannel(channelId, embed, overrideClient) { 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) const commandLine = (interaction?.commandName || interaction?.customId)
? `Command/Button: ${interaction.commandName || interaction.customId}\n` ? `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({ await channel.send({
content: `\`[${context}]\` ${error.message || String(error)}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\`` content: `\`[${context}]\` ${message}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
}); });
} catch (_) { } catch (_) {
// ignore send failures // ignore send failures
@@ -52,19 +71,12 @@ async function logError(context, error, interaction = null, overrideClient = nul
async function logWarn(context, message, overrideClient = null) { async function logWarn(context, message, overrideClient = null) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`Warning: ${context}`) .setTitle(`Warning: ${context}`)
.setDescription(String(message).slice(0, 4000)) .setDescription(redactPII(String(message)).slice(0, 4000))
.setColor(0xFFFF00) .setColor(0xFFFF00)
.setTimestamp(); .setTimestamp();
await sendToChannel(CONFIG.DEBUGGING_CHANNEL_ID, embed, overrideClient); 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 --- // --- logTicketEvent ---
async function logTicketEvent(action, fields, interaction = null) { async function logTicketEvent(action, fields, interaction = null) {
@@ -76,49 +88,12 @@ async function logTicketEvent(action, fields, interaction = null) {
if (interaction?.user?.tag) { if (interaction?.user?.tag) {
embed.setFooter({ text: interaction.user.tag }); embed.setFooter({ text: interaction.user.tag });
} }
await sendToChannel(CONFIG.LOG_CHAN, embed, interaction?.client); await sendToChannel(CONFIG.LOGGING_CHANNEL_ID, embed, interaction?.client);
} }
// --- logGmail ---
async function logGmail(...args) { return; }
// --- logAutomation ---
async function logAutomation(...args) { return; }
// --- logSecurity ---
async function logSecurity(...args) { return; }
// --- 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(...args) { return; }
module.exports = { module.exports = {
setClient, setClient,
logError, logError,
logWarn, logWarn,
logEvent, logTicketEvent
logTicketEvent,
logGmail,
logAutomation,
logSecurity,
logIntegrity,
logSystem
}; };

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 { google } = require('googleapis');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
@@ -56,8 +56,6 @@ function getGmailClient() {
* *
* Throws if the env file is missing the token, or if the probe call (getProfile) * 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. * fails — the caller surfaces the error so the UI can see why.
*
* @returns {Promise<{emailAddress: string}>}
*/ */
async function reloadGmailClient() { async function reloadGmailClient() {
const envMap = readEnvFile(); const envMap = readEnvFile();
@@ -74,276 +72,53 @@ async function reloadGmailClient() {
return { emailAddress: profile.data.emailAddress }; return { emailAddress: profile.data.emailAddress };
} }
async function sendTicketClosedEmail(ticket, closerName, userId = null) { // 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 { try {
const gmail = getGmailClient(); const thread = await gmail.users.threads.get({ userId: 'me', id: threadId });
const firstMsg = (thread.data.messages || [])[0];
// Send to the ticket sender (customer), not derived from thread (which can be support) const headers = firstMsg?.payload?.headers || [];
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase(); return {
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return; subject: headers.find(h => h.name === 'Subject')?.value || null,
if (!EMAIL_RE.test(recipientEmail)) { msgId: sanitizeHeaderValue(headers.find(h => h.name === 'Message-ID')?.value) || null
logError('sendTicketClosedEmail: invalid recipient', new Error(`Rejected: ${recipientEmail}`)).catch(() => {}); };
return; } catch (_) {
} return { subject: null, msgId: null };
let originalSubject = null;
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const firstMsg = messages[0];
if (firstMsg?.payload?.headers) {
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) originalSubject = subj;
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
const baseSubject = originalSubject || ticket.subject || 'Support';
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
const messageBody = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = signatureBlocks.text;
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(messageBody).replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()}
</div>`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(messageBody);
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
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: ticket.gmailThreadId }
});
} catch (err) {
console.error('Ticket closed email error:', err);
} }
} }
// StaffSignature model is registered in models.js; re-import here for use in this file // Strip leading "Re:" variants and re-prepend a single one, then RFC 2047 encode.
const { mongoose } = require('../db-connection'); function encodeReplySubject(baseSubject) {
const StaffSignature = mongoose.model('StaffSignature'); const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safe = sanitizeHeaderValue(`Re: ${stripped}`);
/** return `=?utf-8?B?${Buffer.from(safe).toString('base64')}?=`;
* 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} 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) {
try {
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;
}
let originalSubject = null;
let msgId = null;
try {
const thread = await gmail.users.threads.get({
userId: 'me',
id: ticket.gmailThreadId
});
const messages = thread.data.messages || [];
const firstMsg = messages[0];
if (firstMsg?.payload?.headers) {
const subj = firstMsg.payload.headers.find(h => h.name === 'Subject')?.value;
if (subj) originalSubject = subj;
msgId = sanitizeHeaderValue(firstMsg.payload.headers.find(h => h.name === 'Message-ID')?.value);
}
} catch (_) {}
// Thread-safety: derive Subject from the last thread message; strip any leading
// Re:/RE:/Re : variants and re-prepend a single "Re: ". Fall back to subjectLine
// (legacy param) only if the thread lookup gave us nothing.
const baseSubject = originalSubject || subjectLine || ticket.subject || 'Support';
const stripped = String(baseSubject).replace(/^(?:\s*Re\s*:\s*)+/i, '');
const safeSubject = sanitizeHeaderValue(`Re: ${stripped}`);
const utf8Subject = `=?utf-8?B?${Buffer.from(safeSubject).toString('base64')}?=`;
let signatureBlocks = { text: '', html: '' };
if (userId) {
signatureBlocks = await getStaffSignatureBlocks(userId);
}
// signatureBlocks.html arrives pre-escaped from getStaffSignatureBlocks.
const safeStaffSigHtml = signatureBlocks.html ? signatureBlocks.html.replace(/\n/g, '<br>') : '';
const safeStaffSigText = signatureBlocks.text;
const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(messageBody || '').replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()}
</div>`;
const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(messageBody || '');
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${recipientEmail}`,
`Subject: ${utf8Subject}`,
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: ticket.gmailThreadId }
});
} catch (err) {
console.error('Ticket notification email error:', err);
}
} }
/** // Compose and send a multipart/alternative reply on an existing Gmail thread.
* Send a Gmail reply to a ticket async function sendThreadedEmail(gmail, { threadId, recipient, encodedSubject, msgId, messageText, userId }) {
* @param {string} threadId - Gmail thread ID const sigBlocks = userId ? await getStaffSignatureBlocks(userId) : { text: '', html: '' };
* @param {string} replyText - Reply text const safeStaffSigHtml = sigBlocks.html ? sigBlocks.html.replace(/\n/g, '<br>') : '';
* @param {string} recipientEmail - Recipient email const safeStaffSigText = sigBlocks.text;
* @param {string} subject - Subject line
* @param {string} messageId - Message ID (optional)
* @param {string} userId - Discord user ID for optional personal valediction/tagline (optional)
*/
async function sendGmailReply(
threadId,
replyText,
recipientEmail,
subject,
messageId,
userId = null
) {
const gmail = getGmailClient();
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')}?=`;
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 htmlBody = ` const htmlBody = `
<div style="font-family: sans-serif; font-size: 14px; color: #333;"> <div style="font-family: sans-serif; font-size: 14px; color: #333;">
<p>${escapeHtml(replyText).replace(/\n/g, '<br>')}</p> <p>${escapeHtml(messageText || '').replace(/\n/g, '<br>')}</p>
${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''} ${safeStaffSigHtml ? `<p style="margin: 10px 0;">${safeStaffSigHtml}</p>` : ''}
${buildCompanySigHtml()} ${buildCompanySigHtml()}
</div>`; </div>`;
const plainBody = [messageText || ''];
if (safeStaffSigText) plainBody.push('', safeStaffSigText);
plainBody.push('', ...buildCompanySigText().split('\n'));
const boundary = '000000000000' + Date.now().toString(16); const boundary = '000000000000' + Date.now().toString(16);
const plainBody = [];
plainBody.push(replyText);
if (safeStaffSigText) {
plainBody.push('');
plainBody.push(safeStaffSigText);
}
plainBody.push('');
plainBody.push(...buildCompanySigText().split('\n'));
const headers = [ const headers = [
`From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`, `From: ${sanitizeHeaderValue(CONFIG.MY_EMAIL)}`,
`To: ${safeRecipient}`, `To: ${recipient}`,
`Subject: ${utf8Subject}`, `Subject: ${encodedSubject}`,
safeMessageId && `In-Reply-To: ${safeMessageId}`, msgId && `In-Reply-To: ${msgId}`,
safeMessageId && `References: ${safeMessageId}`, msgId && `References: ${msgId}`,
'MIME-Version: 1.0', 'MIME-Version: 1.0',
`Content-Type: multipart/alternative; boundary="${boundary}"` `Content-Type: multipart/alternative; boundary="${boundary}"`
].filter(Boolean); ].filter(Boolean);
@@ -366,9 +141,92 @@ async function sendGmailReply(
.toString('base64') .toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
await gmail.users.messages.send({ await gmail.users.messages.send({ userId: 'me', requestBody: { raw, threadId } });
userId: 'me', }
requestBody: { raw, threadId }
// Resolve and validate a customer recipient from a ticket's senderEmail.
// Returns null and logs if invalid or self-addressed.
function resolveCustomerRecipient(ticket, context) {
const recipientEmail = sanitizeHeaderValue(extractRawEmail(ticket.senderEmail || '')).toLowerCase();
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return null;
if (!EMAIL_RE.test(recipientEmail)) {
logError(`${context}: invalid recipient`, new Error(`Rejected: ${recipientEmail}`)).catch(() => {});
return null;
}
return recipientEmail;
}
async function sendTicketClosedEmail(ticket, closerName, userId = null) {
try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketClosedEmail');
if (!recipient) return;
const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || ticket.subject || 'Support');
const messageText = `${closerName} has marked this ticket as resolved. If you would like to re-open this issue, please reply to this email.`;
await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId,
recipient,
encodedSubject,
msgId,
messageText,
userId
});
} catch (err) {
console.error('Ticket closed email error:', err);
}
}
/**
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
* @param {string} subjectLine - Fallback subject if the thread can't be queried
* @param {string} messageBody - Plain or HTML message body
* @param {string} [userId] - Discord user ID for signature (optional)
*/
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, userId = null) {
try {
const recipient = resolveCustomerRecipient(ticket, 'sendTicketNotificationEmail');
if (!recipient) return;
const gmail = getGmailClient();
const { subject, msgId } = await fetchThreadSubjectAndMsgId(gmail, ticket.gmailThreadId);
const encodedSubject = encodeReplySubject(subject || subjectLine || ticket.subject || 'Support');
await sendThreadedEmail(gmail, {
threadId: ticket.gmailThreadId,
recipient,
encodedSubject,
msgId,
messageText: messageBody,
userId
});
} catch (err) {
console.error('Ticket notification email error:', err);
}
}
/**
* 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, 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 gmail = getGmailClient();
await sendThreadedEmail(gmail, {
threadId,
recipient: safeRecipient,
encodedSubject: encodeReplySubject(subject || 'Support'),
msgId: sanitizeHeaderValue(messageId) || null,
messageText: replyText,
userId
}); });
} }

View File

@@ -31,9 +31,9 @@ async function pinMessage(message, client) {
} }
} catch (err) { } catch (err) {
if (err.code === 30003) { 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 { } else {
await logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {}); logWarn('pinMessage', `Failed to pin message in #${message.channel.name}: ${err.message}`, client).catch(() => {});
} }
} }
} }

View File

@@ -11,9 +11,10 @@
* is logged via logWarn. * is logged via logWarn.
* - invitable: false means only staff with MANAGE_THREADS can add additional * - invitable: false means only staff with MANAGE_THREADS can add additional
* members — this is intentional for privacy. * members — this is intentional for privacy.
* - guild.members.fetch() in addRoleMembersToThread can be slow on large * - addRoleMembersToThread reads from role.members (cache-derived) and only
* servers. The 300ms delay between adds avoids the thread member add rate * falls back to a scoped guild.members.fetch on cache miss. The 300ms
* limit (approximately 5/second). * 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 { ChannelType } = require('discord.js');
const { CONFIG } = require('../config'); 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) { 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; return thread;
@@ -48,30 +53,40 @@ async function createStaffThread(channel, client) {
if (err.code === 50024 || err.code === 160004) { 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(() => {}); 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; return null;
} }
} }
/** /**
* Add all members of the staff role to the thread. * 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) { async function addRoleMembersToThread(thread, guild, client) {
try { try {
const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null); const role = await guild.roles.fetch(CONFIG.STAFF_THREAD_ROLE_ID).catch(() => null);
if (!role) return; if (!role) return;
await guild.members.fetch(); let members = role.members.filter(m => !m.user.bot);
const members = guild.members.cache.filter(m => if (members.size === 0) {
m.roles.cache.has(CONFIG.STAFF_THREAD_ROLE_ID) && !m.user.bot // 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) { for (const [, member] of members) {
await thread.members.add(member.id).catch(() => {}); await thread.members.add(member.id).catch(() => {});
await new Promise(r => setTimeout(r, 300)); await new Promise(r => setTimeout(r, 300));
} }
} catch (err) { } catch (err) {
await logError('staffThread:addMembers', err, null, client).catch(() => {}); logError('staffThread:addMembers', err, null, client).catch(() => {});
} }
} }

View File

@@ -2,11 +2,9 @@
* Ticket database helpers counters, rename, limits, auto-close, * Ticket database helpers counters, rename, limits, auto-close,
* reminders, auto-unclaim, channel creation. * reminders, auto-unclaim, channel creation.
*/ */
const { ChannelType, PermissionFlagsBits } = require('discord.js'); const { ChannelType } = require('discord.js');
const { mongoose, withRetry } = require('../db-connection'); const { mongoose, withRetry } = require('../db-connection');
const { CONFIG } = require('../config'); const { CONFIG } = require('../config');
const { getPriorityEmoji } = require('../utils');
const { logAutomation } = require('../services/debugLog');
const { enqueueSend, enqueueDelete } = require('./channelQueue'); const { enqueueSend, enqueueDelete } = require('./channelQueue');
const Ticket = mongoose.model('Ticket'); 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 // primary bot's 2/10min per-channel budget here; 429s from the secondary
// bot surface via utils/renamer.js instead. // 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) { function getSenderLocal(senderEmail) {
return (senderEmail || 'unknown').split('@')[0].toLowerCase(); return (senderEmail || 'unknown').split('@')[0].toLowerCase();
} }
@@ -56,7 +51,12 @@ function toDiscordSafeName(str) {
*/ */
async function resolveCreatorNickname(guild, ticket) { async function resolveCreatorNickname(guild, ticket) {
if (ticket.gmailThreadId.startsWith('discord-')) { 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 { try {
const member = await guild.members.fetch(creatorUserId); const member = await guild.members.fetch(creatorUserId);
return member.displayName; 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) --- // --- RATE LIMIT (per-user ticket creation) ---
const ticketCreationByUser = new Map(); // userId -> { count, resetAt } const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
@@ -156,15 +146,6 @@ function escapeCategoryNameForRegex(name) {
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 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) { function countChannelsInCategory(guild, categoryId) {
return guild.channels.cache.filter(c => c.parentId === categoryId).size; return guild.channels.cache.filter(c => c.parentId === categoryId).size;
} }
@@ -272,52 +253,6 @@ async function cleanupEmptyOverflowCategory(guild, categoryId, categoryName) {
} }
} }
async function createTicketChannel(guild, ticketNumber, userId, subject, creatorNickname) {
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;
}
// --- LIMITS & PERMISSIONS --- // --- LIMITS & PERMISSIONS ---
async function checkTicketLimits(senderEmail) { async function checkTicketLimits(senderEmail) {
@@ -334,22 +269,12 @@ async function checkTicketLimits(senderEmail) {
return { ok: true }; 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 --- // --- ACTIVITY ---
async function updateTicketActivity(gmailThreadId) { async function updateTicketActivity(gmailThreadId) {
const now = new Date();
await Ticket.updateOne( await Ticket.updateOne(
{ gmailThreadId }, { gmailThreadId },
{ $set: { lastActivity: now, reminderSent: false } } { $set: { lastActivity: new Date() } }
); );
} }
@@ -366,9 +291,7 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
lastActivity: { $lt: cutoffTime, $ne: null } lastActivity: { $lt: cutoffTime, $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean()); }).sort({ createdAt: 1 }).limit(500).lean());
let checked = 0, closed = 0;
for (const ticket of staleTickets) { for (const ticket of staleTickets) {
checked++;
try { try {
const guild = client.guilds.cache.first(); const guild = client.guilds.cache.first();
if (!guild) continue; if (!guild) continue;
@@ -387,61 +310,21 @@ async function checkAutoClose(client, sendTicketClosedEmail) {
await sendTicketClosedEmail(ticket, 'Auto-Close System', null); 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(() => { enqueueDelete(channel).then(() => {
withRetry(() => Ticket.updateOne( withRetry(() => Ticket.updateOne(
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $unset: { pendingDelete: '' } } { $unset: { pendingDelete: '' } }
)).catch(() => {}); )).catch(() => {});
}).catch(() => {}); }).catch(() => {});
}, 5000); }, 5000));
closed++;
} }
} catch (error) { } catch (error) {
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, 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) { async function checkAutoUnclaim(client) {
@@ -454,9 +337,7 @@ async function checkAutoUnclaim(client) {
lastActivity: { $lt: unclaimTime, $ne: null } lastActivity: { $lt: unclaimTime, $ne: null }
}).lean()); }).lean());
let checked = 0, unclaimed = 0;
for (const ticket of staleClaimedTickets) { for (const ticket of staleClaimedTickets) {
checked++;
try { try {
const guild = client.guilds.cache.first(); const guild = client.guilds.cache.first();
if (!guild) continue; if (!guild) continue;
@@ -473,18 +354,16 @@ async function checkAutoUnclaim(client) {
); );
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`); console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
unclaimed++;
} }
} catch (error) { } catch (error) {
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, 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) { async function reconcileDeletedTicketChannels(client) {
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID) || client.guilds.cache.first(); 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. // Bounded per-tick; a larger backlog drains in subsequent hourly runs.
const openTickets = await Ticket.find({ const openTickets = await Ticket.find({
@@ -492,9 +371,7 @@ async function reconcileDeletedTicketChannels(client) {
discordThreadId: { $ne: null } discordThreadId: { $ne: null }
}).sort({ createdAt: 1 }).limit(500).lean(); }).sort({ createdAt: 1 }).limit(500).lean();
let checked = 0, reconciled = 0;
for (const ticket of openTickets) { for (const ticket of openTickets) {
checked++;
try { try {
let channel = guild.channels.cache.get(ticket.discordThreadId); let channel = guild.channels.cache.get(ticket.discordThreadId);
if (!channel) { if (!channel) {
@@ -505,17 +382,11 @@ async function reconcileDeletedTicketChannels(client) {
{ gmailThreadId: ticket.gmailThreadId }, { gmailThreadId: ticket.gmailThreadId },
{ $set: { status: 'closed', discordThreadId: null } } { $set: { status: 'closed', discordThreadId: null } }
); );
logAutomation('Reconcile: channel deleted', ticket.discordThreadId, `ticket #${ticket.ticketNumber}`).catch(() => {});
reconciled++;
} }
} catch (err) { } catch (err) {
console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err); console.error(`reconcileDeletedTicketChannels error for ${ticket.gmailThreadId}:`, err);
} }
} }
if (reconciled > 0) {
logAutomation('Reconcile run', null, `checked: ${checked}, reconciled: ${reconciled}`).catch(() => {});
}
return { checked, reconciled };
} }
/** /**
@@ -525,8 +396,7 @@ async function reconcileDeletedTicketChannels(client) {
*/ */
async function resumePendingDeletes(client) { async function resumePendingDeletes(client) {
const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []); const pending = await Ticket.find({ pendingDelete: true }).lean().catch(() => []);
if (!pending.length) return 0; if (!pending.length) return;
let resumed = 0;
for (const ticket of pending) { for (const ticket of pending) {
try { try {
const guild = client.guilds.cache.first(); const guild = client.guilds.cache.first();
@@ -534,7 +404,6 @@ async function resumePendingDeletes(client) {
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null); const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
if (channel) { if (channel) {
enqueueDelete(channel).catch(() => {}); enqueueDelete(channel).catch(() => {});
resumed++;
} }
} }
Ticket.updateOne( Ticket.updateOne(
@@ -545,33 +414,22 @@ async function resumePendingDeletes(client) {
console.error('resumePendingDeletes error:', e); console.error('resumePendingDeletes error:', e);
} }
} }
logAutomation('Pending-delete resume', null, `pending: ${pending.length}, resumed: ${resumed}`).catch(() => {});
return resumed;
} }
module.exports = { module.exports = {
getNextTicketNumber, getNextTicketNumber,
getOrCreateTicketCategory, getOrCreateTicketCategory,
cleanupEmptyOverflowCategory, cleanupEmptyOverflowCategory,
RENAME_WINDOW_MS,
RENAME_LIMIT,
getSenderLocal, getSenderLocal,
toDiscordSafeName, toDiscordSafeName,
resolveCreatorNickname, resolveCreatorNickname,
makeTicketName, makeTicketName,
canRename,
minutesFromMs,
checkTicketCreationRateLimit, checkTicketCreationRateLimit,
createTicketChannel,
checkTicketLimits, checkTicketLimits,
hasBlacklistedRole,
updateTicketActivity, updateTicketActivity,
checkAutoClose, checkAutoClose,
checkReminders,
checkAutoUnclaim, checkAutoUnclaim,
reconcileDeletedTicketChannels, reconcileDeletedTicketChannels,
resumePendingDeletes, resumePendingDeletes,
startTicketsSweeps, startTicketsSweeps
sweepTicketCreationByUser,
_internals: { ticketCreationByUser, TICKET_CREATION_SWEEP_TTL_MS }
}; };

View File

@@ -1,82 +0,0 @@
.claude/settings.local.json | 51 ---------
.env.example | 61 +----------
.env.test.example | 128 ----------------------
.gitignore | 7 +-
.gitlab-ci.yml | 19 ----
CLAUDE.md | 129 ----------------------
FEATURES.md | 87 ---------------
api/{bosscordClient.js => botClient.js} | 0
broccolini-discord.js | 80 +-------------
broccolini_bot_context.md | 512 ---------------------------------------------------------------------------------------
commands/register.js | 203 +----------------------------------
config.js | 193 +++------------------------------
docs/CRITICAL_FILES_AND_HOW_IT_WORKS.md | 250 -------------------------------------------
docs/README.md | 50 ---------
docs/api/DISCORD_API_IMPROVEMENTS.md | 665 -----------------------------------------------------------------------------------------------------------------
docs/api/DISCORD_API_VALIDATION.md | 570 -------------------------------------------------------------------------------------------------
docs/architecture/COMMANDS_ANALYSIS.md | 83 ---------------
docs/architecture/CRITICAL_FILES_AND_HOW_IT_WORKS.md | 250 -------------------------------------------
docs/features/FEATURES_SUMMARY.md | 331 ---------------------------------------------------------
docs/features/IMPLEMENTATION_SUMMARY.md | 454 -----------------------------------------------------------------------------
docs/features/NEW_FEATURES.md | 365 --------------------------------------------------------------
docs/features/PHASE_FEATURES.md | 531 -------------------------------------------------------------------------------------------
docs/features/PROPOSAL.md | 72 -------------
docs/features/UPGRADE_COMPLETE.md | 352 ------------------------------------------------------------
docs/reference/Untitled | 13 ---
docs/reference/game-list.md | 97 -----------------
docs/reference/regex-and-games.md | 60 -----------
docs/setup/1PASSWORD.md | 126 ----------------------
docs/setup/ENV_AND_SECURITY.md | 73 -------------
docs/setup/MONGODB_SETUP.md | 166 -----------------------------
docs/setup/PROJECT_STRUCTURE.md | 154 ---------------------------
docs/setup/QUICKSTART.md | 199 ----------------------------------
game-options.json | 32 ------
git | 0
gmail-poll.js | 57 ++++------
handlers/accountinfo.js | 195 ----------------------------------
handlers/analytics.js | 89 ----------------
handlers/buttons.js | 143 +++++++------------------
handlers/commands.js | 519 ++++++++---------------------------------------------------------------------------------
handlers/messages.js | 47 +++-----
handlers/messages.js.bak3-20260421 | 106 ++++++++++++++++++
handlers/setup.js | 656 ----------------------------------------------------------------------------------------------------------------
models.js | 811 ------------------------------------------------------------------------------------------------------------------------------------------
package.json | 6 +-
routes/bosscord.js | 239 -----------------------------------------
routes/internalApi.js | 70 +-----------
scripts/bulk-lookup-users-v2.js | 193 ---------------------------------
scripts/bulk-lookup-users.js | 174 ------------------------------
scripts/export-transcript-embeds.js | 109 -------------------
scripts/fetch-channel-messages.js | 57 ----------
scripts/fetch-channel.js | 51 ---------
scripts/fetch-message.js | 71 -------------
scripts/find-transcript-by-member.js | 80 --------------
scripts/find-transcript-by-owner.js | 92 ----------------
scripts/lookup-user.js | 39 -------
scripts/lookup-with-dedicated-bot.js | 183 --------------------------------
scripts/lookup-with-roles.js | 237 -----------------------------------------
scripts/map-batch-to-transcript.js | 129 ----------------------
scripts/test-mongodb.js | 7 +-
services/chatAlertChecker.js | 98 -----------------
services/configPersistence.js | 4 +-
services/configSchema.js | 37 +------
services/gmail.js | 54 ++++------
services/gmail.js.bak3-20260421 | 346 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
services/guildSettings.js | 33 ------
services/notificationEnabled.js | 102 ------------------
services/notificationRegistry.js | 214 -------------------------------------
services/patternChecker.js | 587 ----------------------------------------------------------------------------------------------------
services/patternStore.js | 286 -------------------------------------------------
services/staffChannel.js | 89 ----------------
services/staffNotifications.js | 149 --------------------------
services/staffPresence.js | 48 ---------
services/surgeChecker.js | 260 ---------------------------------------------
services/tickets.js | 176 +++++++-----------------------
settings-site/CLAUDE.md | 76 -------------
settings-site/public/css/main.css.bak-20260421 | 1026 -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
settings-site/public/index.html.bak-20260421 | 484 -----------------------------------------------------------------------------------
settings-site/public/js/app.js.bak-20260421 | 162 ----------------------------
settings-site/public/js/router.js.bak-20260421 | 52 ---------
settings-site/server.js | 2 +-
utils.js | 10 +-
81 files changed, 670 insertions(+), 14348 deletions(-)

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

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

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

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

140
utils.js
View File

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

View File

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

10
vitest.config.mjs Normal file
View File

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