Repository navigation
Expand file tree
/
Copy pathbot.py
More file actions
1841 lines (1603 loc) Β· 87.5 KB
/
Copy pathbot.py
File metadata and controls
1841 lines (1603 loc) Β· 87.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Curling Club Practice-Ice Bot
Slash command: /sheets [weeks] [stats] [show] (stats:True = streak records, show:True = post board)
Reports practice ice from four sources, in time order: designated practice
blocks, Learn-to-Curls that don't fill the ice, league draws that don't fill the
ice, and facility-reserved curling ice the club hasn't booked over yet (the
rink's public iCal feed, via pond_ice). Sheet counts come from Gravity Forms
(LTC/private events) and the public league pages (leagues, via league_client).
No estimates β unconfirmed sessions are flagged as such.
Configure the target site and club name via environment variables
(SITE_BASE_URL, CLUB_NAME) β see .env.example.
"""
import asyncio
import json
import math
import os
import re
import time
import logging
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
load_dotenv()
import aiohttp
import discord
from discord import app_commands
from discord.ext import commands
from gf_client import GFClient
from ice import PEOPLE_PER_SHEET, TOTAL_SHEETS, sheets_for_people
from league_client import get_cached_leagues, draw_to_datetime
import block_store as bs
import instructors
import practice_ice as pi
import practice_store as ps
import pond_ice
import subs
log = logging.getLogger(__name__)
# ββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
DISCORD_TOKEN = os.environ["DISCORD_TOKEN"]
# Dev mode: set DEV_GUILD_ID to your server's ID to sync slash commands to that
# one guild β they appear instantly, instead of the ~1h global propagation.
# Leave unset in production for a normal global sync.
DEV_GUILD_ID = int(os.environ["DEV_GUILD_ID"]) if os.environ.get("DEV_GUILD_ID") else None
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "https://example.com")
CLUB_NAME = os.environ.get("CLUB_NAME", "Curling Club")
WP_API = f"{SITE_BASE_URL}/wp-json/tribe/events/v1"
# NUM_SHEETS / PEOPLE_PER_SHEET and sheets_for_people() come from ice.py (imported
# above) so the /sheets report and the instructor board can never disagree about
# how much ice a headcount needs.
# How long a session keeps showing in /sheets after it ends, so latecomers still
# see a just-finished slot. Past this grace it drops off (set 0 for a hard cutoff
# at end time). Applies to every source (practice/LTC/league/reserved ice).
SHEETS_GRACE_HOURS = float(os.environ.get("SHEETS_GRACE_HOURS", "1"))
TIMEZONE_OFFSET = -5 # America/Chicago (CST = UTC-5, CDT = UTC-6)
# Facility-reserved curling ice (the rink's own public Google calendar). The
# club can use these blocks but they're not on the club calendar until something
# is booked, so we surface any that nothing club-side overlaps as open practice
# ice. POND_ICS_URLS = comma-separated public iCal URLs (kept in .env, never in
# the repo); POND_MATCH = title keyword that marks a curling block.
POND_ICS_URLS = [u.strip() for u in os.environ.get("POND_ICS_URLS", "").split(",") if u.strip()]
POND_MATCH = os.environ.get("POND_MATCH", "curl")
POND_CACHE_TTL = int(os.environ.get("POND_CACHE_TTL", "21600")) # 6h
# /sheets makes several network calls (calendar + Gravity Forms). That data changes
# slowly, so cache the fetched sessions and reuse them for SHEETS_CACHE_TTL β
# same 6h cadence as the league cache. In-memory, so it also refetches once per app
# launch. See fetch_sessions_cached / current_opportunities.
SHEETS_CACHE_TTL = int(os.environ.get("SHEETS_CACHE_TTL", "21600")) # 6h
# How soon to retry after a failed refetch (we keep serving the stale data until
# then). Short enough to recover quickly, long enough that a down backend isn't
# re-dialled once per button press.
SHEETS_RETRY_SECONDS = int(os.environ.get("SHEETS_RETRY_SECONDS", "120"))
# Practice sign-up pool (shared across members; interactions stay private).
PRACTICE_STORE_PATH = os.environ.get("PRACTICE_STORE_PATH", "practice_signups.json")
_practice_state = ps.load(PRACTICE_STORE_PATH)
_practice_lock = asyncio.Lock()
# Ad-hoc sheet blocks: ice reserved off the books, held by hand from /sheets so
# the report stops advertising sheets that aren't actually there. Kept in its own
# store (and its own lock) because it's written from the block flow while a
# sign-up may be writing the practice pool. See block_store.
BLOCK_STORE_PATH = os.environ.get("BLOCK_STORE_PATH", "sheet_blocks.json")
BLOCK_GRACE_HOURS = float(os.environ.get("BLOCK_GRACE_HOURS", "1"))
_block_state = bs.load(BLOCK_STORE_PATH)
_block_lock = asyncio.Lock()
# ββ Where state actually lives ββββββββββββββββββββββββββββββββββββββββββββββββ
APP_DIR = os.path.dirname(os.path.abspath(__file__))
# Secrets the bot cannot work without. DISCORD_TOKEN is read at import and fails
# immediately, but the Gravity Forms pair is only touched when GFClient() is
# constructed β deep inside the first /sheets call. That meant a missing key
# booted a perfectly healthy-looking bot that then threw KeyError at the first
# member who used the command.
REQUIRED_SECRETS = ("DISCORD_TOKEN", "GF_CONSUMER_KEY", "GF_CONSUMER_SECRET")
def _check_required_env() -> None:
"""Fail at boot, loudly and by name, if a required secret is missing.
Learned the hard way (2026-08-20): prod never declared the Gravity Forms
credentials. It worked anyway because `COPY . .` had baked the developer's
.env into the image and load_dotenv() read it from /app/.env β so production
was quietly running on dev's keys. Adding a .dockerignore removed that file
and the real gap surfaced as a KeyError mid-command. A missing secret should
stop the bot at startup where the operator sees it, not ambush a member."""
missing = [k for k in REQUIRED_SECRETS if not os.environ.get(k)]
if missing:
raise SystemExit(
"FATAL: missing required environment variable(s): " + ", ".join(missing)
+ ".\nThese are secrets: put them in the env file the deployment ships"
" (.env.prod -> .env on the box), NOT in docker-compose. Note that a"
" .env inside the image is no longer read β .dockerignore excludes it"
" on purpose.")
def _audit_store_paths() -> None:
"""Log where every persistent store resolved to, and shout if one landed inside
the app directory.
A store written next to the code is a store written INSIDE THE DOCKER IMAGE.
It looks like it works β the bot reads and writes it all session β but the
container is replaced on every deploy, so the file reverts to whatever was in
the build context on the developer's laptop. That is exactly how a sheet block
placed in dev turned up in production while production's own block vanished
(2026-08-20): BLOCK_STORE_PATH wasn't set in the compose files, so it fell back
to the bare default in the working directory instead of the /data volume.
Silent data loss found by a human noticing wrong numbers is the worst way to
find this, so it goes in the boot log where it can't hide."""
stores = (("BLOCK_STORE_PATH", BLOCK_STORE_PATH),
("PRACTICE_STORE_PATH", PRACTICE_STORE_PATH),
("SUBS_STORE_PATH", subs.STORE_PATH))
for label, path in stores:
resolved = os.path.abspath(path)
try:
inside_app = os.path.commonpath([resolved, APP_DIR]) == APP_DIR
except ValueError: # different drives β can't be inside
inside_app = False
if inside_app:
log.warning(
"%s resolves to %s, inside the app directory β this file is part of "
"the image and WILL BE RESET on the next deploy. Point it at the "
"durable volume (e.g. /data/%s).",
label, resolved, os.path.basename(resolved))
else:
log.info("%s -> %s%s", label, resolved,
"" if os.path.exists(resolved) else " (new)")
# Debounce impatient double-taps of a practice signup button. ps.toggle is a
# joinβleave toggle, so without this a quick double-click would join then
# instantly leave. (user_id, session_key) -> monotonic time of last click.
CLICK_DEBOUNCE_SECONDS = 3.0
_practice_cooldown: dict[tuple[int, str], float] = {}
# Same guard for placing a sheet block β see finish_block.
_block_cooldown: dict[tuple, float] = {}
def _is_repeat_click(cooldown: dict, key) -> bool:
"""Record this click and report whether it repeats `key` within the debounce
window; callers treat a repeat as a no-op. Call under whichever lock guards
the passed dict β _practice_lock for _practice_cooldown, _block_lock for
_block_cooldown."""
now_m = time.monotonic()
last = cooldown.get(key)
cooldown[key] = now_m
if len(cooldown) > 256: # opportunistic prune of stale entries
for k in [k for k, t in cooldown.items() if now_m - t >= CLICK_DEBOUNCE_SECONDS]:
del cooldown[k]
return last is not None and now_m - last < CLICK_DEBOUNCE_SECONDS
PRACTICE_SLUG = "practice"
PRIVATE_EVENT_FORM_ID = 116 # "Private Event Registration"
LTC_FORM_ID = 110 # "Learn to Curl Registration"
PRICE_PER_PERSON = 55 # private event rate per person
# League draws expose a start time but no end; assume a standard draw length
# (typical evening leagues run ~2h15m) for overlap math.
LEAGUE_DRAW_DURATION_MIN = 135
def now_club() -> datetime:
"""Current club-local time (naive), matching calendar/draw datetimes."""
return datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=TIMEZONE_OFFSET)
# ββ Events API (no auth) βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def fetch_events_on_day(date: datetime) -> list[dict]:
day_start = date.replace(hour=0, minute=0, second=0)
day_end = date.replace(hour=23, minute=59, second=59)
return await fetch_events_in_range(day_start, day_end)
async def fetch_events_in_range(start: datetime, end: datetime) -> list[dict]:
"""All published events overlapping [start, end] in one paged sweep
(replaces day-by-day calls β a multi-week window is a single request set)."""
base = {
"start_date": start.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": end.strftime("%Y-%m-%d %H:%M:%S"),
"per_page": 50, "status": "publish",
}
out, page = [], 1
async with aiohttp.ClientSession() as s:
while True:
async with s.get(f"{WP_API}/events", params={**base, "page": page}) as r:
data = await r.json()
batch = data.get("events", [])
out.extend(batch)
if page >= int(data.get("total_pages", 1) or 1) or not batch:
break
page += 1
return out
async def fetch_practices_within(weeks: int) -> list[dict]:
"""Practice blocks starting within the next `weeks` weeks (the /sheets window)."""
now = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=TIMEZONE_OFFSET)
window_end = now + timedelta(weeks=weeks)
async with aiohttp.ClientSession() as s:
async with s.get(f"{WP_API}/events", params={
"categories": PRACTICE_SLUG,
"start_date": now.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": window_end.strftime("%Y-%m-%d %H:%M:%S"),
"per_page": 100, "status": "publish",
}) as r:
events = (await r.json()).get("events", [])
# Defensive: keep only blocks that actually start inside the window, in case the
# calendar API is lenient about end_date.
return [e for e in events
if e.get("start_date") and datetime.fromisoformat(e["start_date"]) < window_end]
# ββ Sheet lookup via Gravity Forms βββββββββββββββββββββββββββββββββββββββββββββ
def event_slugs(event: dict) -> set[str]:
return {c["slug"] for c in event.get("categories", [])}
def is_practice(event: dict) -> bool:
return PRACTICE_SLUG in event_slugs(event)
# Staff mark an event closed in its title before the inventory cap is reached.
# A closed LTC takes the whole rink regardless of the registered headcount.
FULL_TITLE_MARKERS = ("registration full", "sold out", "wait list", "waitlist")
def event_is_full(event: dict) -> bool:
title = str(event.get("title", "")).lower()
return any(m in title for m in FULL_TITLE_MARKERS)
def _people(value) -> int:
"""Parse a 'Number of Entries' (group size) value to an int; 0 if unparseable."""
try:
return int(float(str(value).strip()))
except (ValueError, TypeError):
return 0
def overlaps(e: dict, practice: dict) -> bool:
es = datetime.fromisoformat(e["start_date"])
ee = datetime.fromisoformat(e["end_date"])
ps = datetime.fromisoformat(practice["start_date"])
pe = datetime.fromisoformat(practice["end_date"])
return es < pe and ee > ps
async def entries_for_event(gf: GFClient, form_id: int, event_post_id: int) -> list[dict]:
"""Fetch GF entries whose source_id matches the WP event post ID."""
return await gf.entries(form_id, params={
"paging[page_size]": 200,
"search": '{"field_filters":[{"key":"source_id","value":"' + str(event_post_id) + '"}]}',
})
def _normalize_title(s: str) -> str:
"""Lowercase, normalise dashes, drop full-markers, collapse to alnum words."""
s = str(s).lower()
s = re.sub(r"–|—|β|β", "-", s)
for marker in FULL_TITLE_MARKERS:
s = s.replace(marker, "")
return re.sub(r"[^a-z0-9]+", " ", s).strip()
async def ltc_entries_for_event(gf: GFClient, event: dict) -> list[dict]:
"""
LTC registrations for an event, matched by event DATE (form field 31, ISO),
not source_id β entries for one event are split across several source_ids.
Field 31 includes the year, so it won't collide across seasons.
If two LTCs share a date, the pool has more than one Event Name (field 34);
in that case keep only entries whose Event Name matches this event's title.
"""
date_iso = datetime.fromisoformat(event["start_date"]).strftime("%Y-%m-%d")
pool = await gf.entries(LTC_FORM_ID, params={
"paging[page_size]": 200,
"search": json.dumps({"field_filters": [
{"key": "31", "operator": "is", "value": date_iso}
]}),
})
names = {str(e.get("34", "")) for e in pool}
if len(names) > 1: # multiple LTCs that day β disambiguate by Event Name
target = _normalize_title(event.get("title", ""))
pool = [e for e in pool if _normalize_title(e.get("34", "")) == target]
return pool
async def sheets_for_event(event: dict, gf: GFClient) -> int | None:
"""
Return sheets used by a concurrent event, or None if no GF data exists for this type.
Raises on GF API errors β caller surfaces them to the user.
"""
slugs = event_slugs(event)
event_id = event["id"] # WP post ID β matches source_id in GF entries
# ββ Private events: price paid Γ· PRICE_PER_SHEET β sheets βββββββββββββ
if "privateevents" in slugs:
entries = await entries_for_event(gf, PRIVATE_EVENT_FORM_ID, event_id)
if not entries:
return None
# Field 14.2 holds the negotiated fee, e.g. "$1,000.00"
raw = entries[0].get("14.2", "0")
price = float(str(raw).replace("$", "").replace(",", "").strip() or "0")
people = price / PRICE_PER_PERSON
# Uncapped: a big enough booking can imply more ice than the club has,
# and the caller wants to see that rather than a silent clamp.
return sheets_for_people(people, cap=False)
# ββ Learn to Curls ββββββββββββββββββββββββββββββββββββββββββββββββββββ
if "learn-to-curls" in slugs or "ltc-instructional-leagues" in slugs:
# Staff-closed LTCs ("REGISTRATION FULL" etc.) take the whole rink,
# even below the 32-person cap β headcount Γ· 8 can't detect this.
if event_is_full(event):
return TOTAL_SHEETS
# Matched by event date (field 31), not source_id β see ltc_entries_for_event.
entries = await ltc_entries_for_event(gf, event)
if not entries:
return None
# Field 2 = "Number of Entries" (group size per submission); sum = people.
total_people = sum(_people(e.get("2")) for e in entries)
if total_people == 0:
return None
return sheets_for_people(total_people)
# ββ Other categories (leagues, bonspiels, etc.): no reliable data βββββ
# Leagues: team vs individual registration mix makes counting unreliable.
# Bonspiels: no GF form mapped.
return None
# ββ Session collection (all three practice-ice sources) ββββββββββββββββββββββββ
async def collect_sessions(practices, window_start, window_end, gf) -> list[dict]:
"""
Build a list of ice sessions in [window_start, window_end] from all three
sources: designated practice blocks, LTC/private calendar events, and league
draws. Each session has start/end/type/title/sheets_used (None = unknown).
"""
sessions: list[dict] = []
# 1. Designated practice blocks β occupy no sheets themselves. Skip any that
# already ended or start past the window: the calendar API's start_date filter
# is day-granular, so it can hand back a block from earlier today/yesterday.
for p in practices:
es = datetime.fromisoformat(p["start_date"])
ee = datetime.fromisoformat(p["end_date"])
if ee <= window_start or es >= window_end:
continue
sessions.append({
"start": es,
"end": ee,
"type": "Practice",
"title": p["title"],
"sheets_used": 0,
})
# 2. LTC / private events on the calendar β one ranged fetch, then look up
# every event's sheet count concurrently.
seen: set = set()
candidates: list[tuple] = []
for e in await fetch_events_in_range(window_start, window_end):
if e["id"] in seen or is_practice(e):
continue
es = datetime.fromisoformat(e["start_date"])
ee = datetime.fromisoformat(e["end_date"])
if ee <= window_start or es >= window_end:
continue
slugs = event_slugs(e)
if {"learn-to-curls", "ltc-instructional-leagues"} & slugs:
typ = "LTC"
elif "privateevents" in slugs:
typ = "Private"
else:
continue # bonspiels/social/etc. aren't practice-ice sources
seen.add(e["id"])
candidates.append((e, es, ee, typ))
counts = await asyncio.gather(*(sheets_for_event(e, gf) for e, _, _, _ in candidates))
for (e, es, ee, typ), n in zip(candidates, counts):
sessions.append({
"start": es, "end": ee, "type": typ,
"title": e["title"], "sheets_used": n,
})
# 3. League draws (from the cached league pages).
try:
leagues = await get_cached_leagues()
except Exception as ex: # noqa: BLE001
log.warning("League data unavailable: %s", ex)
leagues = []
for lg in leagues:
if lg.get("ended"):
continue
for d in lg.get("upcoming_draws", []):
dt = draw_to_datetime(d)
if dt is None:
continue
dend = dt + timedelta(minutes=LEAGUE_DRAW_DURATION_MIN)
if dend <= window_start or dt >= window_end:
continue
sessions.append({
"start": dt, "end": dend, "type": "League",
# Same de-noising the subs board uses: "Thursday League β Summer
# 2026 League 3 β Begins August 6" β "Thursday League". The row
# already carries this draw's date/time, so no range here.
"title": subs.league_name(lg.get("title", "")) or "League",
"sheets_used": d.get("sheets_used"),
})
# 4. Facility-reserved curling ice (the rink's public calendar). Surface a
# block only when nothing the club already has booked overlaps it β an
# overlap means that ice is already represented by another row above.
if POND_ICS_URLS:
club_sessions = list(sessions) # snapshot before adding reserved blocks
try:
reserved = await pond_ice.fetch_reserved_curling(
POND_ICS_URLS, window_start, window_end, POND_CACHE_TTL, POND_MATCH)
except Exception as ex: # noqa: BLE001 β a feed hiccup must not break /sheets
log.warning("Reserved-ice fetch failed: %s", ex)
reserved = []
for rv in reserved:
if any(pi.overlaps(rv["start"], rv["end"], s["start"], s["end"]) for s in club_sessions):
continue
sessions.append({
"start": rv["start"], "end": rv["end"],
"type": "Reserved Ice", "title": "", "sheets_used": 0,
})
return sessions
# ββ Discord bot ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
intents = discord.Intents.default()
# Slash-command + button bot only β no text-prefix commands, so we don't need the
# privileged Message Content intent. Using when_mentioned (rather than a literal
# "!" prefix) tells discord.py that, which silences the "message content intent is
# missing" warning without enabling a privileged intent we'd never use.
bot = commands.Bot(command_prefix=commands.when_mentioned, intents=intents)
async def setup_hook():
# First thing in the boot log: prove the config is complete, and say where the
# durable state actually lives. Called here rather than at import time because
# discord.py installs the log handlers inside run(), so anything logged
# earlier goes nowhere.
_check_required_env()
_audit_store_paths()
# Register persistent per-request buttons so they keep working across
# restarts, then load the subs cog (adds the /subs command + board logic).
bot.add_dynamic_items(
subs.NewRequestButton,
subs.AvailableButton,
subs.FillForButton,
subs.RemoveButton,
subs.ShowAllButton,
subs.PageClaimButton,
subs.SeriesClaimButton,
subs.RunPickButton,
# Buttons that live in a DM: the assignment handshake and the "your league has
# teams now" nudge. These outlive the message they were sent on, so they must
# be registered like any board button or they go dead on the next restart.
subs.ConfirmAutoButton,
subs.DropAutoButton,
subs.SetTeamButton,
subs.DriftCancelButton,
JoinPracticeButton,
UnlistPracticeButton,
BlockSheetsButton,
)
await bot.add_cog(subs.Subs(bot))
# Instructor board (adds /instructors + the scheduled channel post). Entirely
# optional: with no channel or sheet id configured the cog never loads and
# nothing else changes.
if instructors.configured():
await bot.add_cog(instructors.Instructors(bot))
else:
log.info("Instructor board not configured (INSTRUCTOR_CHANNEL_ID / SHEET_ID); "
"skipping that cog")
# Sync slash commands once, here (on_ready can fire repeatedly on reconnect).
if DEV_GUILD_ID:
guild = discord.Object(id=DEV_GUILD_ID)
bot.tree.copy_global_to(guild=guild) # stage every command onto the dev guild
synced = await bot.tree.sync(guild=guild) # replaces the guild's set β instant
# Flush the GLOBAL set so commands don't also show as a second (global)
# copy in the dev guild. copy_global_to already captured them above.
bot.tree.clear_commands(guild=None)
await bot.tree.sync()
print(f"π§ Dev mode: synced {len(synced)} command(s) to guild {DEV_GUILD_ID} "
f"(instant) and cleared global commands.")
else:
synced = await bot.tree.sync() # global β up to ~1h to appear
print(f"π Synced {len(synced)} command(s) globally (allow up to ~1h to appear).")
bot.setup_hook = setup_hook
@bot.event
async def on_ready():
cog = bot.get_cog("Subs")
if cog is not None:
await cog.startup() # prune expired requests and refresh each server's board
await restore_practice_board() # re-render the practice board after a (re)boot
print(f"β
Logged in as {bot.user}.")
# ββ Practice ice: live report + open sign-up pool βββββββββββββββββββββββββββ
MAX_SIGNUP_BUTTONS = 20 # leave headroom under Discord's 25-component cap
# Discord's embed-description limit is 4096; stop short so the trailing
# "β¦and N more" line always fits.
MAX_EMBED_DESCRIPTION = 3900
def session_key(opp: dict) -> str:
"""Stable per-slot key from the session start minute (e.g. '20260616T1945')."""
return opp["start"].strftime("%Y%m%dT%H%M")
class SheetsError(Exception):
"""Carries a user-facing message for a failed /sheets fetch."""
async def fetch_sessions(weeks: int) -> list[dict]:
"""Fetch the raw ice sessions in the window (raises SheetsError with a message).
Stops short of practice_opportunities on purpose: what's cached below has to be
only the slow, network-derived half. Blocks live in a local file and change the
instant someone places one, so they're folded in per-render instead β caching
finished opportunities would have meant a block quietly not applying until the
6-hour TTL rolled over."""
try:
practices = await fetch_practices_within(weeks)
except Exception: # noqa: BLE001 β log detail server-side, show a safe message
log.exception("Calendar fetch failed")
raise SheetsError("β Could not reach the event calendar right now β please try again shortly.")
if not practices:
return []
# Drop the "already over" floor back by a grace period so a session that just
# ended still shows briefly (latecomers can see/join it) β but yesterday's is
# long gone. The floor gates every source; the window still extends `weeks`
# ahead of *now*.
now = now_club()
window_start = now - timedelta(hours=SHEETS_GRACE_HOURS)
window_end = now + timedelta(weeks=weeks)
try:
async with GFClient() as gf:
sessions = await collect_sessions(practices, window_start, window_end, gf)
except Exception: # noqa: BLE001 β never surface raw errors (they can carry the GF URL+creds)
log.exception("Registration data fetch failed")
raise SheetsError("β Couldn't load registration data right now β please try again shortly.")
return sessions
# Cache the fetched sessions per `weeks` window so /sheets (and every sign-up
# button re-render) doesn't hit the backend each time. Refetch only when the entry
# is older than SHEETS_CACHE_TTL (or on first use after launch). On a refetch
# failure, serve the stale entry rather than erroring.
_sessions_cache: dict[int, tuple[float, list[dict]]] = {}
_sessions_cache_lock = asyncio.Lock()
def _fresh_sessions(sessions: list[dict]) -> list[dict]:
"""Drop sessions that have already ended, so a long-lived cache never shows a
clearly-past slot; everything still upcoming (within its window) is kept as-is."""
cutoff = now_club() - timedelta(hours=SHEETS_GRACE_HOURS)
return [s for s in sessions if s.get("end") is None or s["end"] > cutoff]
async def fetch_sessions_cached(weeks: int) -> list[dict]:
now_m = time.monotonic()
entry = _sessions_cache.get(weeks)
if entry is None or now_m - entry[0] > SHEETS_CACHE_TTL:
async with _sessions_cache_lock:
entry = _sessions_cache.get(weeks) # re-check: another task may have refetched
if entry is None or time.monotonic() - entry[0] > SHEETS_CACHE_TTL:
try:
sessions = await fetch_sessions(weeks)
except SheetsError:
if entry is not None:
# Re-stamp the stale entry to a short retry instead of
# leaving it expired: otherwise every /sheets and every π«
# press re-dials a dead host while holding this lock, and
# the whole club queues behind one HTTP timeout.
log.warning("Sheets refetch failed β serving cached sessions.")
_sessions_cache[weeks] = (
time.monotonic() - SHEETS_CACHE_TTL + SHEETS_RETRY_SECONDS,
entry[1])
return _fresh_sessions(entry[1])
raise
entry = (time.monotonic(), sessions)
_sessions_cache[weeks] = entry
return _fresh_sessions(entry[1])
async def current_opportunities(weeks: int) -> list[dict]:
"""The practice-ice rows as they stand right now: cached sessions from the
calendar/forms/leagues, plus every live manual block, run through the same
overlap arithmetic. Blocks are applied here (not inside the cache) so placing
one shows up on the very next render."""
return [o for o in await current_sessions_annotated(weeks) if pi.should_show(o)]
async def current_sessions_annotated(weeks: int) -> list[dict]:
"""Every session in the window with its free-sheet count attached, INCLUDING
the ones the display rules hide. current_opportunities is the subset members
see; this is the subset the bot has to reason about."""
sessions = await fetch_sessions_cached(weeks)
async with _block_lock:
if bs.expire(_block_state, now_club(), BLOCK_GRACE_HOURS):
bs.save(BLOCK_STORE_PATH, _block_state)
blocks = bs.as_sessions(_block_state)
return pi.annotate(sessions + blocks, TOTAL_SHEETS)
async def current_sessions_annotated_safe(weeks: int) -> list[dict]:
try:
return await current_sessions_annotated(weeks)
except SheetsError:
log.warning("Couldn't recompute sessions after a block change.")
return []
async def current_opportunities_safe(weeks: int) -> list[dict]:
"""current_opportunities, but a fetch failure yields [] instead of raising β
for the paths (block announcements, board refreshes) where a hiccup upstream
must not take down the action the member just performed."""
try:
return await current_opportunities(weeks)
except SheetsError:
log.warning("Couldn't recompute opportunities after a block change.")
return []
async def build_sheets_payload(weeks: int, user) -> tuple[discord.Embed, discord.ui.View | None]:
"""Build the (ephemeral) practice-ice embed + sign-up view for a member."""
opps = await current_opportunities(weeks)
# Register each session and snapshot sign-up counts under the lock. Sessions are
# keyed by start minute, so two opportunities at the same time share ONE sign-up
# pool β collapse them to a single row + button (a second button would reuse the
# custom_id, which Discord rejects with 50035 "custom id cannot be duplicated").
async with _practice_lock:
ps.expire(_practice_state, now_club())
keyed, seen_keys, capped = [], set(), 0
for o in opps:
key = session_key(o)
if key in seen_keys:
continue
seen_keys.add(key)
label = f"{o['start'].strftime('%a %b %-d')} Β· {o['start'].strftime('%-I:%M %p')}"
ps.register_session(_practice_state, key, when_ts=o["start"].isoformat(),
label=label, sheets=o.get("free"))
keyed.append((o, key, ps.count(_practice_state, key),
ps.is_signed_up(_practice_state, key, user.id)))
if len(keyed) >= MAX_SIGNUP_BUTTONS:
capped = len({session_key(x) for x in opps}) - len(keyed)
break
ps.save(PRACTICE_STORE_PATH, _practice_state)
embed = discord.Embed(title=f"π₯ Practice Ice β {CLUB_NAME}", color=0x1a6bb5)
if not opps:
embed.description = "π΄ No sheets free during the upcoming practice window."
embed.set_footer(text="Only you can see this Β· source: calendar + Gravity Forms + leagues")
# Still offer the block menu: with nothing listed, the manual-entry path is
# the only way to record ice that got reserved off the calendar β and the
# release menu is how a block placed in error gets undone.
empty = discord.ui.View(timeout=None)
empty.add_item(UnlistPracticeButton(weeks))
empty.add_item(BlockSheetsButton(weeks))
return embed, empty
lines, view = [], discord.ui.View(timeout=None)
used, dropped = 0, 0
for o, key, n, mine in keyed:
line = pi.format_opportunity(o, TOTAL_SHEETS)[1]
line += f"\n **{n}** signed up to practice" + (" Β· you're in" if mine else "")
# No free ice β nothing to practice on, so don't let people sign up. Someone
# already signed up keeps a button (to withdraw); everyone else gets none.
full = o.get("free", 0) <= 0
if full and not mine:
line += "\n β no open ice β sign-up closed for this slot"
# Discord hard-rejects an embed description over 4096 characters, and
# discord.py doesn't check β it just 400s, leaving the member on a
# permanent "thinkingβ¦" with the π« menu (the only way to UNDO a block)
# stuck behind the failed render. Blocks add lines per row, so the
# headroom that used to be comfortable no longer is. Drop whole slots
# rather than risk it, and say so.
if used + len(line) + 2 > MAX_EMBED_DESCRIPTION:
dropped += 1
continue
used += len(line) + 2
lines.append(line)
if mine or not full:
view.add_item(JoinPracticeButton(key, weeks, o["start"], n, mine))
# Slots left out for either reason β the component cap or the description
# budget β are reported together; two separate tallies would each under-count.
dropped += capped
if dropped:
lines.append(f"β¦and {dropped} more slot{'s' if dropped != 1 else ''} β "
"run `/sheets` with a smaller `weeks` to see them.")
embed.description = "\n\n".join(lines)
# Sit last, after the slot buttons (20 of those at most, so these two stay
# inside Discord's 25-component cap).
view.add_item(UnlistPracticeButton(weeks))
view.add_item(BlockSheetsButton(weeks))
embed.set_footer(text="Tap a slot to sign up Β· π
if you can't make one "
"Β· π« to hold ice that's booked off the calendar")
return embed, view
def _ordinal(n: int) -> str:
suffix = "th" if 10 <= n % 100 <= 20 else {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
def _place(rank: int, tied: bool) -> str:
"""'the longest' / 'tied for the longest' / '2nd longest' / 'tied for 3rd longest'."""
what = "the longest" if rank == 1 else f"{_ordinal(rank)} longest"
return f"tied for {what}" if tied else what
def streak_signup_note(state: dict, user_id: int, now: datetime) -> str:
"""The streak half of the sign-up ping ('' below two weeks), with a leading space.
Ranked among streaks still GOING β a broken run, however long, doesn't outrank
someone on a live one; the all-time records are for the stats board. The one
exception is a streak that's itself in the all-time top three: that's worth
saying out loud, so it gets a second sentence."""
streak = ps.current_streak(state, user_id, now)
if streak <= 1:
return ""
rank, _total, tied = ps.streak_rank(state, user_id, now)
note = (f" That's a **{streak}-week** practice streak β "
f"{_place(rank, tied)} active streak in the club! π₯")
ever, ever_tied = ps.all_time_rank(state, user_id, now)
if ever == 1:
note += (" It ties the all-time club record! π" if ever_tied
else " That's a new all-time club record! π")
elif 1 < ever <= 3:
note += f" It's the {_place(ever, ever_tied)} streak in club history! π"
return note
# Embed fields die past 1024 characters, and a leaderboard line is unbounded (it
# carries every name in a tied group). Leave room for the "β¦and N others" tail.
STREAK_FIELD_BUDGET = 900
# Names on one line before the rest are summarised, so one huge tie can't push the
# groups below it off the board.
MAX_NAMES_PER_GROUP = 10
def streak_groups(rows: list[dict], key: str) -> list[tuple]:
"""Collapse a sorted leaderboard into [(weeks, [namesβ¦]), β¦], longest first.
One entry per distinct streak LENGTH, not per person β everyone level on three
weeks is one group. `rows` arrives sorted by (-streak, name), so a group is just
a run of equal values and the names inside it come out alphabetical."""
groups: list[tuple] = []
for r in rows:
weeks = r[key]
if groups and groups[-1][0] == weeks:
groups[-1][1].append(r["name"])
else:
groups.append((weeks, [r["name"]]))
return groups
def _streak_rows(rows: list[dict], key: str, n: int = 5) -> str:
"""Format a leaderboard as up to `n` lines β ONE PER STREAK LENGTH, with every
name on that line.
Grouping is the whole point. Listing one person per line repeated the same medal
down the board (three people tied on the club record each got their own π₯ row)
and then jumped to a bare "4." for the next pair, which read as a numbering
glitch rather than as fourth place. A line per group says the same thing without
the repetition: place is the group's position, so the group after a three-way
tie for first is second, and gets π₯.
That's dense ranking, and ps.streak_rank matches it β the "2nd longest active
streak in the club" line on a sign-up has to agree with the board the member is
looking at."""
medals = {1: "π₯", 2: "π₯", 3: "π₯"}
if not rows:
return "β"
groups = streak_groups(rows, key)
out, used, shown_groups = [], 0, 0
for place, (weeks, names) in enumerate(groups[:n], start=1):
listed = names[:MAX_NAMES_PER_GROUP]
rest = len(names) - len(listed)
who = ", ".join(listed) + (f" +{rest} more" if rest else "")
line = (f"{medals.get(place, f'`{place}.`')} **{weeks} wk"
f"{'s' if weeks != 1 else ''}** β {who}")
if used + len(line) + 1 > STREAK_FIELD_BUDGET:
break
out.append(line)
used += len(line) + 1
shown_groups += 1
# Never drop people silently: say how many are still on the board below the cut.
hidden = sum(len(names) for _w, names in groups[shown_groups:])
if hidden:
out.append(f"β¦and {hidden} more with shorter streaks")
return "\n".join(out) if out else "β"
def build_streak_leaderboard_embed() -> discord.Embed:
"""The /sheets stats:True screen β arcade high-score board: who's hot right now,
plus the all-time records that stand forever."""
now = now_club()
current = ps.streak_leaderboard(_practice_state, now)
all_time = ps.all_time_leaderboard(_practice_state)
e = discord.Embed(title=f"π Practice Streak Records β {CLUB_NAME}", color=0xE0632D)
if not current and not all_time:
e.description = ("No streaks on the board yet β sign up two weeks running (and show up!) "
"to get your name up here. Run **/sheets** to find open ice.")
return e
e.add_field(name="π₯ Current streaks", value=_streak_rows(current, "streak"), inline=True)
e.add_field(name="π All-time records", value=_streak_rows(all_time, "best"), inline=True)
e.set_footer(text="Consecutive weeks practiced Β· current resets if you miss a week Β· records stand forever")
return e
def build_practice_board_embed() -> discord.Embed:
"""Public, always-current practice sign-up board (built from the store only)."""
e = discord.Embed(title=f"π§Ή Practice Sign-ups β {CLUB_NAME}", color=0x1a6bb5)
sessions = [s for s in ps.active_sessions(_practice_state) if s.get("users")]
if not sessions:
e.description = "No one's signed up to practice yet.\n\nRun **/sheets** to see open ice and join a slot."
else:
lines = []
for s in sessions:
sheets = s.get("sheets")
free = f"{sheets} sheet{'s' if sheets != 1 else ''} free Β· " if sheets is not None else ""
names = ", ".join(u["name"] for u in s["users"])
# No emoji per date. The broom lives in the board title only β repeated
# down every row (and on every sign-up message) it was pure noise. Row
# emoji are reserved for ones that carry meaning: the π’/π‘/π΄ free-sheet
# status on /sheets, β for a closed slot, π« for a manual block.
lines.append(f"**{s.get('label', s['key'])}** Β· {free}{len(s['users'])} in\n {names}")
e.description = "\n\n".join(lines)
# Current top-5 streaks on the public board (all-time records via /sheets stats:True).
lb = ps.streak_leaderboard(_practice_state, now_club())
if lb:
e.add_field(name="π₯ Current streaks (top 5)", value=_streak_rows(lb, "streak"), inline=False)
e.set_footer(text="/sheets to join Β· /sheets stats:True for all-time records")
return e
async def _post_practice_board(channel):
"""Post a fresh practice board in `channel`, repoint state at it, and delete the
previous board message. Never pinned β visibility comes from being the newest
message in the channel (so the bot needs no Manage Messages permission)."""
async with _practice_lock:
embed = build_practice_board_embed()
old = _practice_state.get("board")
try:
msg = await channel.send(embed=embed)
except discord.HTTPException:
return
async with _practice_lock:
_practice_state["board"] = {"channel_id": channel.id, "message_id": msg.id}
ps.save(PRACTICE_STORE_PATH, _practice_state)
if old:
try:
ch = bot.get_channel(old["channel_id"]) or await bot.fetch_channel(old["channel_id"])
if ch is not None:
await ch.get_partial_message(old["message_id"]).delete()
except discord.HTTPException:
pass
async def bump_practice_board(channel):
"""Publish the updated board by reposting it to the BOTTOM of `channel`, so the
change is visible to everyone in the chat flow. Used on every sign-up / cancel."""
if channel is None:
return
async with _practice_lock:
ps.expire(_practice_state, now_club())
ps.save(PRACTICE_STORE_PATH, _practice_state)
await _post_practice_board(channel)
async def render_practice_board(target_channel=None):
"""Edit the existing board in place (no repost); if it's gone, or none exists and
a `target_channel` is given, (re)post one. Used on boot, and by the block flow
(which passes the board's OWN channel β see _board_channel β so a block never
relocates it). Sign-ups use bump_practice_board so the update surfaces at the
bottom of the channel."""
async with _practice_lock:
ps.expire(_practice_state, now_club())
embed = build_practice_board_embed()
board = _practice_state.get("board")
ps.save(PRACTICE_STORE_PATH, _practice_state)
if board:
try:
ch = bot.get_channel(board["channel_id"]) or await bot.fetch_channel(board["channel_id"])
# get_partial_message() makes no API call and editing our own message
# doesn't need Read Message History; NotFound only if it was deleted.
await ch.get_partial_message(board["message_id"]).edit(embed=embed)
return
except discord.NotFound:
pass # deleted while we were down β repost below if we have a target
except discord.Forbidden as e:
if e.code == 50005: # authored by another bot identity β drop stale pointer
async with _practice_lock:
_practice_state["board"] = None
ps.save(PRACTICE_STORE_PATH, _practice_state)
else:
log.warning("Couldn't edit practice board %s: %s", board["message_id"], e)
return
except discord.HTTPException as e:
log.warning("Couldn't edit practice board %s: %s", board["message_id"], e)
return
if target_channel is not None:
await _post_practice_board(target_channel)
async def restore_practice_board():
"""On (re)boot, re-establish the practice board from its stored pointer: refresh
its contents in place, reposting it in the same channel if the message was
deleted while we were down. No-op if no board has been created yet (it's created
on the first /sheets sign-up). Mirrors the subs cog's startup()."""
board = _practice_state.get("board")
if not board:
return
try:
ch = bot.get_channel(board["channel_id"]) or await bot.fetch_channel(board["channel_id"])
except discord.HTTPException as e:
log.warning("Couldn't resolve practice board channel on boot: %s", e)
return
if ch is not None:
# Passing the stored channel means render reposts there if the message is
# gone, rather than silently giving up (it has no target otherwise).
await render_practice_board(ch)
class JoinPracticeButton(discord.ui.DynamicItem[discord.ui.Button],
template=r"sheet:join:(?P<key>\d{8}T\d{4}):(?P<weeks>\d+)"):
def __init__(self, key: str, weeks: int, start: datetime | None = None,
count: int = 0, mine: bool = False):
self.key = key
self.weeks = int(weeks)
# No type prefix: the report line directly above each button already names
# the slot type and free sheets, and the button colour signals state. So the
# button just shows the time (+ current sign-up count), e.g. "6/20 1:30PM (0)".
# "Leave" stays on the joined state since that affordance isn't obvious from
# colour alone. Fall back to parsing the key when reconstructed from a
# custom_id (start isn't encoded; the live label is re-rendered on build).
when_dt = start
if when_dt is None:
try:
when_dt = datetime.strptime(key, "%Y%m%dT%H%M")
except ValueError:
when_dt = None
when = when_dt.strftime('%-m/%-d %-I:%M%p') if when_dt else key
label = f"Leave {when} ({count})" if mine else f"{when} ({count})"
super().__init__(discord.ui.Button(
label=label[:80],
style=discord.ButtonStyle.secondary if mine else discord.ButtonStyle.success,
custom_id=f"sheet:join:{key}:{weeks}",
))
@classmethod
async def from_custom_id(cls, interaction, item, match):
return cls(match["key"], int(match["weeks"]))
async def callback(self, interaction: discord.Interaction):
# Instant "busy" feedback: grey out the report's buttons the moment they tap,
# so the click visibly registers and an impatient double-tap can't slip a
# second toggle in before we re-render. Live buttons return with the refresh