From aa4b0bc86bb1e2de8b2c63aa441467317fed3674 Mon Sep 17 00:00:00 2001 From: Ibraheem Saleh Date: Sun, 11 Jan 2026 20:11:59 -0800 Subject: [PATCH] booking configuration validation --- README.md | 12 +++- config.json | 13 +++- harbor_bot.py | 188 +++++++++++++++++++++++++++++++++++++++++++------- prompts.txt | 6 +- 4 files changed, 189 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 50f2c4b..90c4faf 100644 --- a/README.md +++ b/README.md @@ -106,18 +106,26 @@ List of available courts: ``` ### Reservation Behaviors -Defines how reservations are attempted: +Defines how reservations are attempted. **Important: Times must be in ascending order.** + ```json { "Name": "Weekday_Reservation_Type", "DaysInAdvance": 7, "AttemptSchedule": [ {"Time": "04:29:00-08:00", "Attempts": 2, "DelaySeconds": 10}, - {"Time": "04:30:00-08:00", "Attempts": 10, "DelaySeconds": 10} + {"Time": "04:30:00-08:00", "Attempts": 10, "DelaySeconds": 10}, + {"Time": "04:35:00-08:00", "Attempts": 1, "DelaySeconds": 0} ] } ``` +- `DaysInAdvance`: How many days before the reservation date to attempt booking +- `AttemptSchedule`: List of attempt windows (must be in chronological order) + - `Time`: When to start attempting (in timezone format like "04:30:00-08:00") + - `Attempts`: Number of booking attempts in this window + - `DelaySeconds`: Delay between attempts + ### Reservations Scheduled reservation configurations: ```json diff --git a/config.json b/config.json index b58d1da..d8d76c0 100644 --- a/config.json +++ b/config.json @@ -38,6 +38,17 @@ {"Time": "04:35:00-08:00", "Attempts": 1, "DelaySeconds": 0} ] }, + { + "Name": "Test_Reservation_Type", + "DaysInAdvance": 7, + "AttemptSchedule": [ + {"Time": "04:29:00-08:00", "Attempts": 2, "DelaySeconds": 10}, + {"Time": "04:31:00-08:00", "Attempts": 2, "DelaySeconds": 10}, + {"Time": "04:35:00-08:00", "Attempts": 1, "DelaySeconds": 0}, + {"Time": "20:05:00-08:00", "Attempts": 4, "DelaySeconds": 10}, + {"Time": "20:11:00-08:00", "Attempts": 4, "DelaySeconds": 10} + ] + }, { "Name": "Weekend_Reservation_Type", "DaysInAdvance": 7, @@ -110,7 +121,7 @@ { "Day": "Sunday", "Time": "15:30:00-08:00", - "ReservationBehavior": "Weekday_Reservation_Type", + "ReservationBehavior": "Test_Reservation_Type", "Courts": [ {"Court": "Court_3", "Account": "Saleh"} ] diff --git a/harbor_bot.py b/harbor_bot.py index 2edbcc3..142a704 100644 --- a/harbor_bot.py +++ b/harbor_bot.py @@ -172,10 +172,49 @@ def load_config(config_path: str = "config.json") -> Config: if "Name" not in beh or "DaysInAdvance" not in beh or "AttemptSchedule" not in beh: raise ValueError(f"Invalid reservation behavior: {beh}") + if not beh["AttemptSchedule"]: + raise ValueError(f"ReservationBehavior '{beh['Name']}' must have at least one AttemptSchedule entry") + schedules = [] - for sched in beh["AttemptSchedule"]: + prev_time_minutes = -1 + + for i, sched in enumerate(beh["AttemptSchedule"]): if "Time" not in sched or "Attempts" not in sched or "DelaySeconds" not in sched: - raise ValueError(f"Invalid attempt schedule: {sched}") + raise ValueError(f"Invalid attempt schedule in '{beh['Name']}': {sched}") + + # Validate time format and check ascending order + time_str = sched["Time"] + try: + # Parse time "HH:MM:SS-TZ:00" format + if len(time_str) < 8: + raise ValueError(f"Invalid time format") + time_part = time_str[:8] + parts = time_part.split(':') + if len(parts) != 3: + raise ValueError(f"Invalid time format") + hour = int(parts[0]) + minute = int(parts[1]) + second = int(parts[2]) + + if not (0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second <= 59): + raise ValueError(f"Invalid time values") + + # Convert to minutes for comparison + current_time_minutes = hour * 60 + minute + + if current_time_minutes <= prev_time_minutes: + raise ValueError( + f"AttemptSchedule times in '{beh['Name']}' must be in ascending order. " + f"Entry {i+1} time '{time_str}' is not after previous entry. " + f"Please sort AttemptSchedule entries by time." + ) + prev_time_minutes = current_time_minutes + + except ValueError as e: + if "ascending order" in str(e) or "AttemptSchedule" in str(e): + raise + raise ValueError(f"Invalid time format '{time_str}' in '{beh['Name']}': {e}") + schedules.append(AttemptSchedule( time=sched["Time"], attempts=sched["Attempts"], @@ -681,21 +720,85 @@ class HarborBot: print("[ERROR] Failed to authenticate. Exiting.") return + # Print initial status showing next scheduled attempts + self._print_upcoming_reservations() + + last_status_print = datetime.now() + while True: try: self._daemon_iteration(dry_run) - # Sleep until next check (check every minute) - time.sleep(60) + # Print status every 30 minutes + if (datetime.now() - last_status_print).total_seconds() > 1800: + self._print_upcoming_reservations() + last_status_print = datetime.now() + + # Sleep until next check (check every 30 seconds for better timing accuracy) + time.sleep(30) except KeyboardInterrupt: print("\n[INFO] Daemon stopped by user") break except Exception as e: print(f"[ERROR] Daemon error: {e}") + import traceback + traceback.print_exc() print("[INFO] Sleeping 60s before retry...") time.sleep(60) + def _print_upcoming_reservations(self): + """Print status of upcoming reservations.""" + print(f"\n[STATUS] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Checking upcoming reservations:") + + upcoming = [] + + for reservation in self.config.reservations: + behavior = self.config.reservation_behaviors[reservation.reservation_behavior] + target_date = self.calculate_reservation_date(reservation.day, behavior.days_in_advance) + + if not target_date: + continue + + # Get timezone from reservation time + _, _, _, tz_offset = self._parse_schedule_time(reservation.time) + now = self._get_current_time_in_tz(tz_offset) + today = now.date() + + # Find next upcoming attempt window + next_attempt_time = None + for schedule in behavior.attempt_schedule: + sched_time = self._get_schedule_datetime(schedule.time, datetime.combine(today, datetime.min.time())) + if sched_time > now: + next_attempt_time = sched_time + break + + if next_attempt_time: + wait_seconds = (next_attempt_time - now).total_seconds() + upcoming.append({ + 'reservation': reservation, + 'target_date': target_date, + 'next_attempt': next_attempt_time, + 'wait_seconds': wait_seconds + }) + + if not upcoming: + print(" No reservations scheduled for today") + else: + # Sort by next attempt time + upcoming.sort(key=lambda x: x['next_attempt']) + for item in upcoming: + res = item['reservation'] + wait = item['wait_seconds'] + if wait < 60: + wait_str = f"{wait:.0f}s" + elif wait < 3600: + wait_str = f"{wait/60:.1f}m" + else: + wait_str = f"{wait/3600:.1f}h" + print(f" - {res.day} @ {res.time} -> {item['target_date'].strftime('%Y-%m-%d')}: " + f"Next attempt at {item['next_attempt'].strftime('%H:%M:%S')} (in {wait_str})") + def _daemon_iteration(self, dry_run: bool): """Single iteration of the daemon loop.""" # Re-check authentication periodically @@ -720,31 +823,64 @@ class HarborBot: now = self._get_current_time_in_tz(tz_offset) today = now.date() - # Get first and last attempt times - first_schedule = behavior.attempt_schedule[0] - last_schedule = behavior.attempt_schedule[-1] - - first_time = self._get_schedule_datetime(first_schedule.time, datetime.combine(today, datetime.min.time())) - last_time = self._get_schedule_datetime(last_schedule.time, datetime.combine(today, datetime.min.time())) - - # Add buffer for last attempt window (assume max 10 minutes for attempts) - last_time_with_buffer = last_time + timedelta(minutes=10) - - # Check if we're before the first window (wait) or past the last window (skip) - if now < first_time - timedelta(minutes=5): - # More than 5 minutes before first attempt - not time yet - wait_seconds = (first_time - now).total_seconds() - if wait_seconds < 300: # Less than 5 minutes away - print(f"\n[INFO] {reservation.day} @ {reservation.time}: Starting in {wait_seconds:.0f}s") - continue + # Find if we're within any attempt window (within 60 seconds of a scheduled time) + for schedule in behavior.attempt_schedule: + sched_time = self._get_schedule_datetime(schedule.time, datetime.combine(today, datetime.min.time())) + time_diff = (now - sched_time).total_seconds() + + # Trigger if we're within 0-60 seconds after the scheduled time + if 0 <= time_diff <= 60: + print(f"\n[TRIGGER] {reservation.day} @ {reservation.time} for {target_date.strftime('%Y-%m-%d')}") + print(f"[INFO] Attempt window: {schedule.time} (triggered at {now.strftime('%H:%M:%S')})") + self._execute_single_attempt_window(reservation, schedule, target_date, dry_run) + break # Only execute once per iteration + + def _execute_single_attempt_window( + self, + reservation: Reservation, + schedule: AttemptSchedule, + target_date: datetime, + dry_run: bool + ): + """Execute attempts for a single attempt window.""" + reservation_datetime = target_date.strftime(f"%Y-%m-%dT{reservation.time[:8]}") + reservation.time[8:] + reservation_date_str = target_date.strftime("%Y-%m-%d") + + print(f"[INFO] Booking for: {reservation_datetime}") + print(f"[INFO] Attempts: {schedule.attempts}, Delay: {schedule.delay_seconds}s") + + for court_res in reservation.courts: + court = self.config.courts[court_res.court] + account = self.config.accounts[court_res.account] - if now > last_time_with_buffer: - # Past all attempt windows for today + # Check if already successful + if self._is_reservation_successful(reservation_date_str, court_res.court, court_res.account): + print(f" [SKIP] {court.name} for {account.name} - already booked successfully") continue - # We're within the attempt window - execute - print(f"\n[TRIGGER] {reservation.day} @ {reservation.time} for {target_date.strftime('%Y-%m-%d')}") - self.execute_reservation_attempts(reservation, behavior, target_date, dry_run) + for attempt in range(schedule.attempts): + if dry_run: + print(f" [DRY RUN] Attempt {attempt + 1}/{schedule.attempts}: " + f"Would book {court.name} for {account.name}") + else: + print(f" [ATTEMPT] {attempt + 1}/{schedule.attempts}: " + f"Booking {court.name} for {account.name}...") + + result = self.book_appointment(court, account, reservation_datetime) + + if result.get("Success"): + print(f" [SUCCESS] Booked {court.name} for {account.name}") + self._mark_reservation_successful(reservation_date_str, court_res.court, court_res.account) + break + else: + print(f" [FAILED] {result.get('Message', 'Unknown error')}") + if result.get("AllowRetry") is False and "no longer available" in result.get("Message", ""): + print(f" [INFO] Slot no longer available, moving to next court") + break + + if attempt < schedule.attempts - 1 and schedule.delay_seconds > 0: + if not dry_run: + time.sleep(schedule.delay_seconds) # After executing, we'll skip this reservation until tomorrow # (the success tracking prevents re-booking, and time checks prevent re-triggering) diff --git a/prompts.txt b/prompts.txt index a176cd7..6ce376f 100644 --- a/prompts.txt +++ b/prompts.txt @@ -61,11 +61,15 @@ curl 'https://future.ourclublogin.com/api/CustomerAuth/CustomerLogin' \ ** We need to add behavior so that if a reservation succeeds, future attempts for that configuration do not try again. -* Prompt 2: +## * I tried to book a court for 7 days in advance with a weekend reservation type, and it booked the court right away, instead of waiting for the specified booking times. The bot should only attempt to make the reservations in live mode at the time of the reservation. * Instead of having to start the bot every day, update it to just run continuously. If it every crashes, the service will just auto-restart... No reason to require 7 cron jobs--1 for every day. +## +* Add startup configuration validation to ensure that the AttemptSchedule is configured properly, with scheduled times incrementing as required by the algorithm. +## +* This will run on a raspberry pi on linux, implement a pure python method for sending an email everytime a court is successfully booked. Be sure to include what court was booked, what time it was booked for, and the user name and ID that the booking was creating under.