Skip to content

broomhead/curlbot

Repository files navigation

Curling Club Discord Bot

A Discord bot that helps a curling club coordinate ice time. It has three features:

  • Practice ice (/sheets) — reports how many sheets are free during upcoming practice ice, read live from the club's WordPress site (The Events Calendar, Gravity Forms registrations, and the public league pages).
  • Practice sign-ups — an open pool to say "I want to practice this slot," with a shared, auto-updating board so members can rally when someone's going.
  • Subs board (/subs) — "I need a sub" / "I can sub" coordination tied to real league teams and game dates, with spot-filling, DM confirmations, and notifications.

Everything is configured for a single site via environment variables — the target domain, club name, and sheet count are not hardcoded. The number of sheets is set with NUM_SHEETS (default 4), since facilities differ.

Practice ice — what it shows

/sheets [upcoming] lists practice-ice opportunities in time order, each as time · type · sheets free · date. The reply is private (ephemeral — only you see it), so checking it doesn't post in the channel. Practice ice comes from four source types:

  • Practice blocks — designated open-ice sessions on the calendar.
  • Learn-to-Curls — sheet usage from registration headcount (ceil(people / 8)).
  • Private events — sheet usage derived from the booking fee.
  • Leagues — team count and draw schedule parsed from the public league pages.

Free sheets during any session = NUM_SHEETS − sheets used by every overlapping session, so concurrent bookings stack correctly. Sessions with no available data are flagged rather than guessed. NUM_SHEETS (default 4) is configurable since facilities differ.

upcoming (1–5, default 1) is how many designated practice blocks to look ahead; that span defines the window in which LTCs, private events, and league draws are also surfaced.

Practice sign-up pool. Each opportunity has a sign-up button. Tap it to say "I want to practice this slot"; tap again to drop off. There's no cap — it's an open pool, so the report just shows free sheets and how many people have signed up, and members sort out sheets themselves. Sign-ups are shared (everyone sees the counts) even though each /sheets view is private; they age out a few hours after the slot's start time.

Because people often show up when someone else is going, a shared practice board is kept current in the channel: it's posted/pinned the first time someone signs up (in whatever channel they used /sheets) and edited silently on every join or leave. When someone newly joins a slot, the bot also posts a short ping so others get notified and can join in.

Subs board

The subs board coordinates "I need a sub" / "I can sub" through buttons. Two commands:

  • /subs opens your private copy of the board (ephemeral — only you see it, nothing is posted to the channel). Use it to check what's open and act.
  • /subsboard posts the shared board to the channel and pins it (restricted to members who can manage messages). Run it once per channel.

Both show the same board; every action (taking a spot, posting a request, inviting a sub) updates the shared pinned board, and acting from your private board refreshes it in place. The buttons:

  • ➕ Need a sub walks you through league → your team → which game → how many spots. Leagues, teams, and games are pulled live from the club's league pages (the same source as /sheets), so you pick from real data instead of typing it. (No game listed? Pick Enter date manually.) After posting you can invite an available sub right away.
  • A numbered Sub for … button appears per open request. Click to take an open spot, click again to drop it. The requester is notified of every change.
  • 🙋 I can sub lets you pick a league and the upcoming games you can cover (or none, for "any game"), listing you on the board's available-subs section.
  • 🛠 Manage (requester only) lets you add/remove a member directly, invite an available sub (they get a DM to confirm), or close a request early.

When inviting an available sub, they receive a DM with Confirm / Can't buttons; the spot is held as pending (shown on the board) until they accept, then it flips to filled. Either way the requester is notified.

Game pickers show all upcoming games for the chosen league. Requests carry the game's date/time and auto-expire a few hours after it starts (SUBS_GRACE_HOURS, default 3); availability tied to specific games expires once those games pass — checked every 15 minutes and on startup.

Notifications try a DM first and fall back to an @-mention in the board channel if the member has DMs closed.

State is kept in a small JSON file (SUBS_STORE_PATH, default subs_store.json) and the buttons survive bot restarts. The board is generic by design (each request has a kind field), so the same machinery can later back pickup games, team-building, etc.

Requires discord.py >= 2.4 (persistent per-request buttons). For pinning, invite the bot with the Manage Messages permission; without it the board still works, just unpinned.

Setup

  1. Create a Discord application and bot (Developer Portal → New Application → Bot → copy the token). Under OAuth2 → URL Generator, select bot + applications.commands and the Send Messages, Embed Links, and Manage Messages permissions (the last is needed to pin the boards), then invite it.
  2. Copy .env.example to .env and fill in the values (Discord token, site URL, club name, sheet count, Gravity Forms REST key/secret).
  3. Run it. The bot syncs slash commands on startup (/sheets, /subs, /subsboard); set DEV_GUILD_ID to your server for instant command updates while testing (otherwise a global sync can take up to ~1h to appear).

Docker (recommended)

docker compose up --build -d

Plain Python

pip install -r requirements.txt
python bot.py

Configuration

All configuration is via environment variables (see .env.example):

Variable Purpose
DISCORD_TOKEN Discord bot token
DEV_GUILD_ID Optional. Server ID for instant slash-command sync (dev). Unset = global sync (~1h).
SITE_BASE_URL Target WordPress site, no trailing slash
CLUB_NAME Name shown in the Discord embed
NUM_SHEETS Number of sheets at the facility (default 4)
PRACTICE_STORE_PATH Practice sign-up pool state file (default practice_signups.json)
GF_CONSUMER_KEY / GF_CONSUMER_SECRET Gravity Forms REST API v2 credentials
LEAGUE_CACHE_TTL League-page cache lifetime in seconds (default 21600 = 6h)
TIMEZONE_OFFSET Club UTC offset for subs date/time parsing (default -5)
SUBS_STORE_PATH Subs board state file (default subs_store.json)
SUBS_GRACE_HOURS Hours after game start before a request expires (default 3)

Sheet count is set via NUM_SHEETS. A few other site-specific constants live at the top of bot.pyPEOPLE_PER_SHEET, PRICE_PER_PERSON, TIMEZONE_OFFSET, the form IDs, and the practice category slug. Adjust to match your site.

How data is sourced

  • Calendar & event details: The Events Calendar REST API (tribe/events/v1).
  • LTC / private registrations: Gravity Forms REST API v2. Credentials are passed as query parameters so they survive proxies that strip Authorization headers. LTC entries are matched by event date, summed across submissions.
  • Leagues: league pages are fetched and parsed (standings → team count; schedule → upcoming draw day/time/sheets), then cached to league_cache.json. refresh_leagues.py force-refreshes the cache and is suitable for a cron job.

Caching

League data is cached locally with a TTL (default 6h); calendar and Gravity Forms data are fetched live per command via a single ranged request with concurrent lookups.

Security & privacy

  • Secrets live only in .env (the Discord token and Gravity Forms key/secret), which is gitignored. Nothing sensitive is committed; .env.example holds only placeholders. No club identity is baked into source — everything is env-driven.
  • Runtime data stays local and out of git: subs_store.json, practice_signups.json, and league_cache.json (plus their .tmp siblings) are gitignored. These hold Discord display names and numeric IDs only.
  • Errors are never echoed raw to Discord. Gravity Forms credentials ride in the request query string (to survive proxies that strip auth headers), so a failed request's default error would embed the URL — and the credentials. gf_client raises a sanitized error (status + path only) and commands log full detail server-side while showing a generic message. When adding new code, never interpolate a raw exception or request URL into a user-facing message.
  • Member-facing commands are private: /sheets and /subs reply ephemerally. The shared practice board and the subs board intentionally show display names so members can coordinate. DM confirm/decline actions are validated against the invited user's ID, so only the invitee can respond.

Developer scripts

One-off exploration/diagnostic scripts (run via docker compose run --rm curlbot python <script>.py):

  • discover_views.py — list Gravity Forms and sample entries.
  • discover_leagues.py — probe league data sources.
  • discover_league_pages.py — validate league-page parsing.
  • discover_ltc.py — diagnose an LTC's headcount and join key.

Hosting

Any always-on machine works — a small VPS, a Raspberry Pi, or a free-tier cloud service. Set the environment variables and run the bot.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors