A production Discord bot that automates FC Clubs cash-cup tournaments — signup, manual payment tracking, random group draws, round-robin scheduling, dual-sided result submission, qualification, and knockout progression, all driven by buttons, modals, and dropdowns rather than slash commands for normal users. Also includes a self-provisioning support ticket system and welcome/goodbye messages.
Current status: every pipeline a real cup needs to run start-to-finish is built and verified live — signup, payment tracking, groups, dual-sided result submission (with staff conflict resolution and an evidence/dispute request), knockout progression, and a declared champion. The tournament clock fires automatically end-to-end: signups close, groups get generated, fixtures open for submission at kickoff, overdue fixtures get reminded and then staff-alerted, and once results come in — including staff-entered voids and forfeits — the knockout stage automatically draws and plays itself out to a winner, the winner is marked prize-pending with a 24h staff reminder, and the tournament finalizes itself at midnight cleanup, all without staff intervention. Staff also have void/forfeit/ disqualify overrides and a
/tournament repairdiagnostic + force- progression command for edge cases — see PLAN.md for the two remaining, deliberately-scoped gaps. Welcome/goodbye messages are built but blocked: they need the GuildMembers privileged intent enabled in the Discord Developer Portal (Bot tab → Server Members Intent) — see below. Only one cash cup runs at a time per server, posted to one fixed sign-up channel — see "Creating a tournament" below.
- Polished tournament announcement embed, edited in place as signups/ payments/state change (never spammed as new messages)
- Button/modal/select-driven signup: team name → co-manager search → cash-cup rules acceptance → entry created
- Automatic manager/co-manager nicknaming (
Team Name M/Team Name CO), safely shortened to fit Discord's 32-character limit without ever cutting off the suffix, with original nicknames snapshotted for restoration - Staff payment control panel (confirm/reject/refund-track, per-entry)
- Configurable premium-role priority signup window
- Withdrawal / kick / tournament-ban kept as three distinct concepts, never conflated
- Seeded, audit-reproducible random group draws — exact groups of 4, excess teams become an ordered reserve list
- Circle-method round-robin fixture generation (3 rounds × 2 matches per group of 4)
- Points → goal difference → alphabetical standings, with zero other tiebreakers, fully unit-tested
- Qualification engine: automatic top-two, best-third-place fill to the next power of two, and an explicit wildcard fallback for the rare case where even every third-place team can't reach the target — never generates a bye
- Full knockout pipeline: unbiased random draw (no seeding, no protecting group winners), a category + role + 3 channels auto-created per round exactly like groups, a bracket graphic, and champion declaration — verified live through a full Semi Finals → Final run to a real winner
- Dual-sided result submission: a results-panel embed per group/round with a fixture picker, home/away-normalized score modals (manager, co-manager, or staff), silent auto-resolve when both sides agree, and a staff conflict panel when they don't — every submission authorized server-side against the real database, not trusted from the interaction — verified live end-to-end (matching submissions, a mismatch, a staff override, and an unauthorized-submission attempt all confirmed)
- Staff "Confirm Group Complete" button once every fixture in a group is resolved — posts the final standings graphic pinging the group role and pins it, one-time only
- Automatic knockout progression: the moment every fixture in every group reaches a terminal state, the knockout draw fires on its own; the moment a knockout round is fully resolved, the next round draws itself, all the way to a declared champion — no staff button required
- Pre-kickoff "Confirm My Roster" button per group, with staged reminders and a deadline that flags any still-unconfirmed team to staff (no automatic reserve promotion — a human always makes that call)
- Overdue-fixture reminders: a first ping to whichever side hasn't
submitted, then a staff alert (and an
OVERDUEstatus) if it's still outstanding - Staff void/forfeit/disqualify overrides on any fixture or entry, all audit-logged and feeding the same automatic knockout-progression trigger as a normal result
- Winner marked prize-pending with a 24-hour staff reminder if unpaid — a staff nudge only, this bot never collects payment details or moves money
- Automatic midnight cleanup finalizes a finished tournament's status, and
/tournament repairgives staff a diagnostic report plus a manual force-progression button for anything that gets stuck - "Request Evidence" button on the staff conflict panel, routing disputing teams to the ticket system for screenshots
- Fixtures graphic posted and pinned at the top of each group/round's chat channel; the results channel is reserved for the results panel
- Branded, monochrome SVG→PNG tournament graphics (group fixtures, group standings, knockout bracket, winner announcement) with the MindSet shield logo embedded, rendered via Sharp with content-hash caching so unchanged data never re-renders
- Database-backed job scheduler (
FOR UPDATE SKIP LOCKEDrow-locking, so multiple bot processes can never double-claim a job), with retry backoff, dead-lettering, and crash-lease reconciliation on startup — and the tournament clock is actually wired to it: signup close and group publication fire automatically, verified live end-to-end - Self-provisioning support ticket system — one staff command posts a panel with a button per ticket type; opening a ticket auto-creates a private channel (and the shared category, first time) with zero configuration
- Welcome/goodbye messages, auto-detecting the welcome channel by name so no setup is required (blocked on a Developer Portal intent toggle — see "Current status" above)
- Zod-validated environment configuration, Pino structured logging, Postgres via Drizzle ORM with committed SQL migrations
See PLAN.md for the full module map, database schema, and an honest phase-by-phase build status including everything that is not built yet.
Stack: Node.js (ESM) · TypeScript strict mode · discord.js v14 · PostgreSQL via Drizzle ORM · Sharp (SVG→PNG) · Zod · Pino · Luxon · Vitest · ESLint · Prettier.
- Node.js ≥ 20
- npm (not pnpm/yarn)
- A PostgreSQL database (a free Neon instance works fine for development)
- A Discord application + bot token (see below)
- Go to https://discord.com/developers/applications → New Application.
- Bot tab → Reset Token, copy it → this is
DISCORD_TOKEN. - General Information tab → copy the Application ID → this is
DISCORD_CLIENT_ID. - Bot tab → leave Message Content Intent OFF (every user-facing
flow is button/modal-driven, nothing parses message text). Turn
Server Members Intent ON — the welcome/goodbye feature needs the
GuildMemberAdd/GuildMemberRemovegateway events, which this privileged intent gates.discord/intents.tsrequestsGuilds+GuildMembers; if this toggle is off, the gateway will reject the connection outright with "Used disallowed intents" — verified live. If you don't want welcome/goodbye, you can removeGatewayIntentBits.GuildMembersfromREQUIRED_INTENTSinstead of enabling the portal toggle; nothing else in the bot needs it. - OAuth2 → URL Generator: scopes
bot+applications.commands.
Select these under Bot Permissions when generating the invite URL (the bot creates/manages roles and channels for groups and knockout stages, so it needs):
- Manage Roles
- Manage Channels
- View Channels
- Send Messages
- Embed Links
- Attach Files (for rendered graphics)
- Read Message History
- Manage Messages (to pin the fixtures/bracket graphic in each chat channel)
- Manage Nicknames
- Add Reactions
- Use Application Commands
Invite the bot, then in Server Settings → Roles, drag the bot's own role above every role it needs to assign or rename (Manager/Co-Manager nicknaming and group roles both require this — if the bot's role sits below a target user's highest role, Discord silently rejects the rename/ role-assign and the bot logs + surfaces a non-fatal warning instead of failing the whole action).
Any Postgres 14+ instance works. For Neon specifically: create a project,
copy the pooled connection string (postgres://user:pass@host/db?sslmode=require)
into DATABASE_URL.
cp .env.example .env
Then fill in every variable — see .env.example for the
full annotated list. All of it is validated at startup via
src/config/env.ts using Zod; a missing or invalid
variable fails fast with every problem listed at once, not one at a time.
npm install
cp .env.example .env # fill in real values
npm run db:migrate # apply the committed SQL migrations
npm run commands:deploy # register slash commands (COMMAND_DEPLOY_MODE=guild for instant dev propagation)
npm run dev # tsx watch — auto-restarts on file changesnpm run typecheck # tsc --noEmit
npm run lint # eslint
npm run lint:fix
npm run format # prettier --write
npm test # vitest run (144 tests as of this writing)
npm run test:watch
npm run test:coverage
npm run db:generate # generate a new migration after editing src/database/schema/
npm run db:studio # Drizzle Studio — browse the DB in a local UInpm install
npm run build # compiles src/ -> dist/ via tsconfig.build.json
npm start # node dist/index.js — no ts-node/tsx at runtimeCorrection, verified live against the real container: this project was
originally documented (wrongly) as using the egg's npm install && npm run build && npm start sequence. The actual egg in use here runs a fixed
startup script instead:
if [ -f /home/container/package.json ]; then /usr/local/bin/npm install; fi;
npx ts-node /home/container/${BOT_TS_FILE}
That means: no build step runs at all — it executes the compatibility
entrypoint directly via ts-node, driven by the BOT_TS_FILE panel
variable. Set BOT_TS_FILE=bootstrap.cjs.
bootstrap.cjs is deliberately a plain-JavaScript Pterodactyl compatibility
launcher. It prints synchronous startup checkpoints, registers ts-node's
transpile-only ESM loader programmatically, and then starts the real
application entrypoint at src/main.ts. This provides correct NodeNext ESM
resolution on Node 24 even when the host omits development-only type packages.
No console command or NODE_OPTIONS loader variable is required.
Setup steps:
- Create the server, point it at this repository.
- Set
BOT_TS_FILE=bootstrap.cjsalongside every application variable from.env.example. Do not commit a real.envfile — it's gitignored. - Before first start (or after any schema change), run
npm run db:migrateonce via the panel's console. - Run
npm run commands:deployonce after every deploy where slash commands changed (/setup,/tournament,/payments,/ticket-panelcurrently). SetCOMMAND_DEPLOY_MODE=globalfor production once you're past active development — global propagation can take up to an hour,guildmode is instant but only registers toDISCORD_GUILD_ID. - Also make sure the GuildMembers privileged intent is enabled (see above) — without it the bot connects to the database fine and then fails immediately with "Used disallowed intents," which looks like a different problem but is the same portal toggle.
If you'd rather not rely on ts-node at runtime at all, the standard
npm install && npm run build && npm start flow (compiles to dist/,
then runs plain Node — no loader flags needed) also works and was verified
separately; it just isn't what this specific egg's fixed startup script
invokes, so switching to it means overriding the egg's startup command in
the panel rather than only setting variables.
npm run commands:deployDeploys /setup, /tournament, /payments, /ticket-panel. Controlled by
COMMAND_DEPLOY_MODE (guild | global) and, for guild mode,
DISCORD_GUILD_ID.
Run /setup configure as a server administrator. It's a two-page wizard:
role selectors (admin/staff roles, premium role, participant role), the
rules channel, the audit-log channel, and the tournament sign-up channel.
That's it — there is no category selector. Every group and every knockout
round gets its own Discord category, auto-created (and remembered) the
first time it's needed; staff never link one manually. Run /setup status
at any time to see exactly what's still missing — the bot refuses to
publish a tournament until every required piece of configuration is
present, and tells you precisely which ones.
/tournament create name:<string> date:<YYYY-MM-DD> [channel:<#channel>] [entry_fee_pence:<int>] — posts the persistent
tournament announcement embed. channel is optional; it defaults to the
tournament channel set in /setup. Only one cup runs at a time per
server — /tournament create refuses to start a second one while any
non-finished tournament exists for the guild (TournamentAlreadyActiveError),
rather than the multi-channel, multi-cup-per-week model an earlier draft of
this bot assumed. From there the flow is entirely button-driven for both
managers and staff (see the Product Overview in PLAN.md).
/payments opens the staff payment control panel: pick a tournament, pick
a team from a searchable list, and use Confirm/Undo/Reject/Refund-tracking
buttons, plus a Disqualify Team button (with a confirm step) for
removing an active entry from the competition entirely. Every action is
logged to the audit-events table.
Fully automatic once a tournament is created — /tournament create
enqueues PREMIUM_CUTOFF, SIGNUP_CLOSE, GROUP_PUBLISH, and
MIDNIGHT_CLEANUP jobs at their resolved schedule times. At kickoff,
GROUP_PUBLISH builds the eligible (payment-confirmed) pool, runs the
random draw, creates each group's own category + role + chat/results/
staff channels, assigns the group role to every manager/co-manager,
generates the round-robin fixtures, posts and pins the fixtures graphic in
the chat channel, posts a roster-confirmation panel and the results panel,
and enqueues a FIXTURE_READY job per fixture plus the group's
GROUP_CONFIRMATION_REMINDER/GROUP_CONFIRMATION_DEADLINE jobs — all
verified live against a real tournament and the real guild. From there,
overdue-fixture reminders, the automatic knockout draw/advance, the prize-
pending deadline, and midnight cleanup all run themselves without staff
intervention (see "Result submission, staff conflict resolution" and
"Cleanup / Repair system" below).
/tournament test [team_count] [cleanup] (staff only) runs the entire
group-publish and knockout pipeline immediately, against fake signups
and simulated results, instead of waiting for a real signup window, real
submitted results, and a real kickoff time. team_count has no upper
bound (default 8). It creates that many fake clubs/entries — all
payment-confirmed, all managed by whoever runs the command so real Discord
role-assignment actually gets exercised — runs the exact same production
pipeline GROUP_PUBLISH uses, then fake-resolves every group fixture with
a random score, runs the knockout draw, and keeps simulating results and
advancing rounds until a champion is decided. It verifies every phase
along the way (right membership/fixture counts, every Discord
category/role/channel actually created for both groups and knockout
rounds, every graphic actually posted, final tournament status COMPLETED)
and replies with a phase-by-phase ✅/❌ report so you can see precisely
where it broke if it did. By default (cleanup:true) it deletes every
Discord category/role/channel and database row it created afterward; pass
cleanup:false to leave everything in place for manual inspection — test
resources are named with a TEST-/TEST prefix so they're easy to spot
and remove by hand later. It's safe to run alongside a real live
tournament: that prefix keeps its group codes and round stage names (and
therefore its Discord category/channel names) from ever colliding with a
real tournament's "Group A", "Quarter Finals", etc. — and since only one
real tournament ever runs at a time, the prefix doesn't need to be random
to guarantee that.
Every fixture — group or knockout — gets a results panel posted to its
results channel the moment the group/round is published: an embed listing
every fixture with a live status, and a select menu of everything
currently submittable. Once a fixture's scheduled kickoff time arrives
(the FIXTURE_READY job walks it SCHEDULED → READY → WAITING_FOR_SUBMISSIONS automatically), picking it from the menu opens a
score modal:
- Manager or co-manager (whichever side they're on — detected server-side, never trusted from the client): "Your score" / "Opponent's score", plus optional penalty fields for knockout fixtures.
- Staff: "Home score" / "Away score" directly — staff input is authoritative and resolves the fixture immediately, no matching needed.
A manager's submission is stored (their previous one, if any, is
deactivated but kept for audit history, not deleted) and checked against
whatever the opponent has on file. If the opponent hasn't submitted yet,
the fixture waits. If both have submitted and they match, the fixture
silently auto-resolves. If they disagree, the fixture flags
RESULT_CONFLICT and a conflict panel posts to the staff channel showing
both submissions side by side, with buttons to accept either one or
manually override with a fresh score. The results panel refreshes in
place (edited, never reposted) after every submission or resolution.
Every submission is authorized server-side against the actual database state, not trusted from the interaction itself: a manager can only submit for the specific team they manage or co-manage, re-checked at the moment they submit (not just when the menu was first shown), and a staff-only action re-checks staff membership the same way — a tampered or forged interaction can point at a fixture, but it can never bypass who's actually allowed to act on it.
Once every fixture in a group is resolved, staff get a Confirm Group
Complete button on that group's results panel (disabled until every
fixture actually has a result). Confirming posts the final standings
graphic in the group's chat channel, pinging the group role and pinning
it, and locks the button so it can't be triggered twice. Separately —
whether or not staff ever click that button — the moment every fixture in
every group of the tournament reaches a terminal status
(RESOLVED/FORFEIT/VOID), the knockout draw fires automatically;
the same automatic trigger fires the next round once the current one's
fixtures are all settled, all the way to a declared champion. Staff can
also void a fixture, award a forfeit win (with a conventional 3-0
scoreline so it counts correctly in standings), or disqualify a team
entirely from the results panel / payment panel — all of these feed the
same automatic trigger.
The domain logic behind all of this (standings, qualification, knockout draw, home/away result normalization, knockout-specific validation) was built and unit-tested earlier in this project; this is the Discord-facing wiring that finally calls it. Verified live against the real database and guild: a matching dual submission, a mismatched one, a staff override, an unauthorized submission attempt, a group confirmation (including correctly rejecting a second attempt), a void, a forfeit (with standings credit confirmed), and a full automatic run from every group fixture resolved through to a declared champion, prize-pending handoff, and midnight cleanup all behaved correctly. What couldn't be verified interactively is the actual button-click/modal-submit UI — there's no second Discord account available to click as a "user" distinct from staff (same limitation as the ticket system) — verification went through the same service functions the real Discord handlers call. See PLAN.md's "Known gaps" for the two remaining, deliberately-scoped gaps.
Staff run /ticket-panel once, anywhere, to post the panel — no other
setup. A member clicks a ticket-type button; the bot creates (or reuses)
a "Support Tickets" category and a private channel just for them, visible
only to them and staff. Claim Ticket marks who's handling it; Close Ticket (available to the opener or staff) closes it out and deletes the
channel a few seconds later. Add a new ticket type by adding one entry to
TICKET_TYPES in src/config/constants.ts — nothing else needs to change.
No setup — both events look for a text channel whose name contains
"welcome" and post there. Requires the GuildMembers privileged intent
to be enabled in the Developer Portal (see above); until then the bot
cannot connect to Discord at all if GatewayIntentBits.GuildMembers stays
in REQUIRED_INTENTS.
Every tournament gets a MIDNIGHT_CLEANUP job enqueued at its scheduled
cleanup time. A tournament that's COMPLETED/CANCELLED by then is
walked to CLEANED automatically — a status marker only, this never
deletes real Discord resources or database rows (unlike /tournament test's diagnostic cleanup, which only ever touches its own throwaway test
tournaments). A tournament still mid-flight past its own midnight is left
alone and staff get an alert pointing them at /tournament repair.
/tournament repair (staff-only) shows a diagnostic report for the
guild's currently active tournament — per-group fixture/roster-
confirmation counts, per-knockout-round status, and how many fixtures are
overdue — with two buttons:
- Force Check Progression manually re-runs the automatic knockout- advance check for every group and round, for the rare case where the automatic trigger didn't fire on its own.
- Cancel Tournament (behind a confirm step) ends the active
tournament and finalizes it straight to
CLEANED, immediately clearing the "one cup at a time" lock so a new one can be created — the fix for a stuck, mistakenly-created, or abandoned-test tournament. It's status-only: no Discord channels/roles are deleted and no entry/payment data changes, so handle refunds or prize situations separately first if the tournament had real money on the line.
- "Invalid environment configuration" on startup — the Zod validator lists every missing/invalid variable at once; fix them all before retrying.
- Bot can't rename a manager/co-manager — check the bot's role position in Server Settings → Roles; it must sit above every role the target user holds. The bot logs this and continues rather than failing the whole signup, per section 7.
- Slash commands not showing up —
guildmode only registers toDISCORD_GUILD_IDand needsnpm run commands:deployre-run after any command change;globalmode can take up to an hour to propagate on first deploy. - Migrations fail to apply — confirm
DATABASE_URLis reachable from wherever you're runningnpm run db:migrate(Neon and most managed Postgres providers require?sslmode=require).
Postgres is the single source of truth for everything except the rendered
graphic PNG cache (GRAPHICS_CACHE_DIR, safely regenerable — content-hash
cached, never load-bearing). Back up the database on whatever schedule your
Postgres provider offers (Neon: point-in-time restore is enabled by
default). Nothing in this system stores payment credentials — payment
tracking is manual status only, so there's no financial-credential data to
protect beyond what's already in your Discord bot token and database
connection string.
-
npm install && npm run build && npm startcompletes with no errors and the bot logs "Discord client ready." (requires the GuildMembers intent to be enabled in the portal — see above — or the connection will fail with "Used disallowed intents") -
npm run db:migrateapplies cleanly against a fresh database -
npm run commands:deployregisters/setup,/tournament,/payments,/ticket-paneland they appear in Discord -
/setup statuscorrectly lists missing configuration on a fresh guild, and shows nothing missing after/setup configurecompletes -
/tournament createposts the announcement embed with correct branding, schedule times, and a working Sign Up button, and enqueues its three scheduler jobs - Clicking Sign Up: team name modal → co-manager select → rules acceptance → entry created, manager/co-manager nicknamed correctly, ephemeral confirmation shown
-
/paymentsshows the new entry asAWAITING_PAYMENT; Confirm Payment moves it toPAYMENT_CONFIRMEDand the announcement embed's paid-team count updates - At the tournament's group-publish time, groups actually appear: own
category + role + 3 channels per group, fixtures graphic posted and
pinned in the chat channel, a results panel posted in the results
channel, tournament status moves to
GROUP_CONFIRMATION - At a fixture's scheduled kickoff time, it moves
SCHEDULED → READY → WAITING_FOR_SUBMISSIONSand appears in the results panel's select menu - Picking a fixture from the results panel shows the right modal (Your
score/Opponent's score for a manager, Home/Away for staff); a
manager's first submission moves the fixture to
WAITING_FOR_OPPONENT; matching submissions from both sides auto-resolve it; mismatched submissions move it toRESULT_CONFLICTand post a conflict panel to the staff channel; the results panel updates in place after each of these - The staff conflict panel's "Accept Submission 1/2" and "Manual Override" buttons all resolve the fixture correctly
- A manager cannot submit for a fixture they aren't part of (rejected with a permission error, both at fixture-select time and if a submission is somehow forced through to the modal-submit step)
- Once every fixture in a group is resolved, "Confirm Group Complete" becomes clickable; clicking it posts the standings graphic pinging the group role, pins it, and the button becomes disabled/relabeled "✅ Group Confirmed" — clicking again (or via another group) is correctly refused if already confirmed
- The "Confirm My Roster" button on a group's chat channel marks the
clicking manager's entry
CONFIRMED; an entry stillPENDINGat the deadline is markedINACTIVE_PENDING_REPLACEMENTand staff are alerted in the group's staff channel - A fixture left unsubmitted gets a reminder ping, then (if still
unsubmitted) flips to
OVERDUEwith a staff alert - Staff can void a fixture (no score, no winner) or award a forfeit win (correct winner, 3-0 scoreline, and the win shows up in the group standings) from the results panel's staff action buttons
- The moment every fixture in every group reaches a terminal status,
the knockout draw fires without clicking anything; the moment a
knockout round is fully resolved, the next round draws itself the
same way, all the way to a declared champion and tournament status
COMPLETED - The declared champion gets a graphic announcement (not a plain
message) and is marked
PRIZE_PENDING -
/tournament repairshows a diagnostic report for the active tournament, and its "Force Check Progression" button re-runs the knockout-advance check without erroring on an already-current tournament -
/tournament repair's "Cancel Tournament" button (behind its "Yes, cancel it" confirm step) ends the active tournament and finalizes it toCLEANED;/tournament createcan then start a new one immediately - The staff conflict panel's "Accept Submission 1/2", "Manual
Override", and "Request Evidence" buttons all behave correctly —
Request Evidence moves the fixture to
EVIDENCE_REQUESTEDand posts to the group/round's chat channel - A manager cannot submit for a fixture they aren't part of (rejected with a permission error, both at fixture-select time and if a submission is somehow forced through to the modal-submit step)
-
/payments' "Disqualify Team" button (with its confirm step) correctly marks an entryDISQUALIFIED -
/tournament test(default options) reports all phases ✅ — including the knockout pipeline phases, ending in a declared champion and tournament statusCOMPLETED— and cleans up after itself; check the guild afterward to confirm no leftoverTEST-*/TEST *categories/channels/roles and no leftoverTEST RUNtournament -
/ticket-panelposts the panel; clicking a ticket type creates a private channel; Claim and Close buttons both work - A member joining/leaving posts to the welcome channel (once the GuildMembers intent is enabled)
-
npm testpasses all 144 tests -
npm run typecheckandnpm run lintare both clean