diff --git a/.gitignore b/.gitignore index 5d381cc..7a47142 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,7 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# This is the actual login token data +token_data.json +reservation_success.json diff --git a/README.md b/README.md index d887ca7..50f2c4b 100644 --- a/README.md +++ b/README.md @@ -20,19 +20,58 @@ python harbor_bot.py --show-config # Test login python harbor_bot.py --test-login -# Run scheduler (dry run - shows what would happen) -python harbor_bot.py --run +# Run as daemon (recommended - runs continuously) +python harbor_bot.py --daemon + +# Run daemon in live mode (actually makes reservations at scheduled times) +python harbor_bot.py --daemon --live -# Run scheduler (live - actually makes reservations) -python harbor_bot.py --run --live +# Run once and check for reservations (one-shot mode) +python harbor_bot.py --run # Test booking a specific court (dry run) python harbor_bot.py --test-book Court_1 Saleh 2026-01-19 "19:00:00-08:00" -# Test booking a specific court (live) +# Test booking a specific court (live - actually books!) python harbor_bot.py --test-book Court_1 Saleh 2026-01-19 "19:00:00-08:00" --live ``` +## Daemon Mode + +The recommended way to run the bot is in daemon mode (`--daemon`). It will: + +1. Run continuously, checking for reservations every minute +2. Wait for the exact scheduled times in the ReservationBehavior before attempting +3. Automatically refresh authentication when needed +4. Track successful reservations to avoid duplicates + +To run in production with auto-restart on crash, use a process manager like systemd: + +```ini +# /etc/systemd/system/harbor-bot.service +[Unit] +Description=Harbor Bot Pickleball Reservation Service +After=network.target + +[Service] +Type=simple +User=your-user +WorkingDirectory=/path/to/harbor-bot +ExecStart=/usr/bin/python3 harbor_bot.py --daemon --live +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Then enable and start: +```bash +sudo systemctl enable harbor-bot +sudo systemctl start harbor-bot +sudo journalctl -u harbor-bot -f # View logs +``` + ## Configuration The `config.json` file contains: diff --git a/config.json b/config.json index 92695fd..b58d1da 100644 --- a/config.json +++ b/config.json @@ -47,7 +47,7 @@ {"Time": "06:55:00-08:00", "Attempts": 2, "DelaySeconds": 10}, {"Time": "07:00:00-08:00", "Attempts": 1, "DelaySeconds": 0}, {"Time": "07:01:00-08:00", "Attempts": 2, "DelaySeconds": 10}, - {"Time": "07:02:00-08:00", "Attempts": 2, "DelaySeconds": 10}, + {"Time": "07:02:00-08:00", "Attempts": 2, "DelaySeconds": 10} ] } ], @@ -107,6 +107,14 @@ {"Court": "Court_4", "Account": "Treve"} ] }, + { + "Day": "Sunday", + "Time": "15:30:00-08:00", + "ReservationBehavior": "Weekday_Reservation_Type", + "Courts": [ + {"Court": "Court_3", "Account": "Saleh"} + ] + }, { "Day": "Saturday", "Time": "14:00:00-08:00", @@ -117,6 +125,6 @@ {"Court": "Court_3", "Account": "LisaK"}, {"Court": "Court_4", "Account": "JimK"} ] - }, + } ] } diff --git a/harbor_bot.py b/harbor_bot.py index bc7ae23..2edbcc3 100644 --- a/harbor_bot.py +++ b/harbor_bot.py @@ -521,6 +521,70 @@ class HarborBot: } return {"error": "Could not calculate next reservation"} + + def _parse_schedule_time(self, time_str: str) -> tuple: + """Parse a schedule time string like '04:29:00-08:00' into (hour, minute, second, tz_offset_hours).""" + # Parse time portion + time_part = time_str[:8] # "04:29:00" + tz_part = time_str[8:] # "-08:00" + + parts = time_part.split(':') + hour = int(parts[0]) + minute = int(parts[1]) + second = int(parts[2]) + + # Parse timezone offset + tz_sign = 1 if tz_part[0] == '+' else -1 + tz_hours = int(tz_part[1:3]) + tz_offset = tz_sign * tz_hours + + return (hour, minute, second, tz_offset) + + def _get_schedule_datetime(self, schedule_time: str, for_date: datetime) -> datetime: + """Get a datetime object for a schedule time on a specific date.""" + hour, minute, second, tz_offset = self._parse_schedule_time(schedule_time) + + # Create timezone-aware datetime + tz = timezone(timedelta(hours=tz_offset)) + return datetime( + for_date.year, for_date.month, for_date.day, + hour, minute, second, + tzinfo=tz + ) + + def _get_current_time_in_tz(self, tz_offset_hours: int) -> datetime: + """Get current time in the specified timezone offset.""" + tz = timezone(timedelta(hours=tz_offset_hours)) + return datetime.now(tz) + + def _wait_until(self, target_time: datetime, dry_run: bool = False) -> bool: + """ + Wait until the target time. + + Returns True if we reached the time, False if interrupted. + """ + while True: + now = datetime.now(target_time.tzinfo) + if now >= target_time: + return True + + wait_seconds = (target_time - now).total_seconds() + + if wait_seconds > 60: + # Long wait - sleep in chunks and show status + if dry_run: + print(f" [DRY RUN] Would wait {wait_seconds:.0f}s until {target_time.strftime('%H:%M:%S')}") + return True + print(f" [WAIT] {wait_seconds:.0f}s until {target_time.strftime('%H:%M:%S %Z')}") + time.sleep(min(60, wait_seconds)) + elif wait_seconds > 0: + if dry_run: + print(f" [DRY RUN] Would wait {wait_seconds:.1f}s") + return True + time.sleep(wait_seconds) + else: + return True + def execute_reservation_attempts( self, reservation: Reservation, @@ -530,6 +594,7 @@ class HarborBot: ): """ Execute reservation attempts according to the behavior schedule. + Waits for the scheduled times before making attempts. Args: reservation: The reservation configuration @@ -545,9 +610,25 @@ class HarborBot: print(f"[INFO] Target date: {reservation_datetime}") print(f"[INFO] Using behavior: {behavior.name}") + # Get timezone from reservation time for scheduling + _, _, _, tz_offset = self._parse_schedule_time(reservation.time) + today = self._get_current_time_in_tz(tz_offset).date() + for schedule in behavior.attempt_schedule: + # Calculate the exact time for this attempt window + schedule_datetime = self._get_schedule_datetime(schedule.time, datetime.combine(today, datetime.min.time())) + print(f"\n[INFO] Attempt window: {schedule.time} - {schedule.attempts} attempts with {schedule.delay_seconds}s delay") + # Wait for the scheduled time + now = self._get_current_time_in_tz(tz_offset) + if now < schedule_datetime: + print(f"[INFO] Waiting for scheduled time: {schedule_datetime.strftime('%H:%M:%S %Z')}") + self._wait_until(schedule_datetime, dry_run) + elif (now - schedule_datetime).total_seconds() > 300: # More than 5 minutes past + print(f"[SKIP] Attempt window {schedule.time} has passed (>5 min ago)") + continue + for court_res in reservation.courts: court = self.config.courts[court_res.court] account = self.config.accounts[court_res.account] @@ -583,9 +664,95 @@ class HarborBot: else: time.sleep(schedule.delay_seconds) + def run_daemon(self, dry_run: bool = True): + """ + Run as a continuous daemon, checking for and executing reservations. + + Args: + dry_run: If True, don't actually make reservations, just print what would happen + """ + print(f"\n{'='*60}") + print(f"Harbor Bot Daemon - {'DRY RUN' if dry_run else 'LIVE MODE'}") + print(f"{'='*60}") + print(f"[INFO] Started at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"[INFO] Configured reservations: {len(self.config.reservations)}") + + if not self.ensure_authenticated(): + print("[ERROR] Failed to authenticate. Exiting.") + return + + while True: + try: + self._daemon_iteration(dry_run) + + # Sleep until next check (check every minute) + time.sleep(60) + + except KeyboardInterrupt: + print("\n[INFO] Daemon stopped by user") + break + except Exception as e: + print(f"[ERROR] Daemon error: {e}") + print("[INFO] Sleeping 60s before retry...") + time.sleep(60) + + def _daemon_iteration(self, dry_run: bool): + """Single iteration of the daemon loop.""" + # Re-check authentication periodically + if self._needs_login(): + print("[INFO] Refreshing authentication...") + if not self.login(): + print("[ERROR] Failed to refresh authentication") + return + + # Check each reservation + for reservation in self.config.reservations: + behavior = self.config.reservation_behaviors[reservation.reservation_behavior] + + # Calculate if we should make this reservation today + target_date = self.calculate_reservation_date(reservation.day, behavior.days_in_advance) + + if not target_date: + continue # Not scheduled for today + + # Check if we're within the attempt window for today + _, _, _, tz_offset = self._parse_schedule_time(reservation.time) + 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 + + if now > last_time_with_buffer: + # Past all attempt windows for today + 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) + + # After executing, we'll skip this reservation until tomorrow + # (the success tracking prevents re-booking, and time checks prevent re-triggering) + def run_scheduler(self, dry_run: bool = True): """ - Main scheduler loop that checks for reservations to make. + One-time scheduler check (for backward compatibility). + Use run_daemon() for continuous operation. Args: dry_run: If True, don't actually make reservations, just print what would happen @@ -716,8 +883,9 @@ def main(): parser.add_argument("--config", default="config.json", help="Path to configuration file") parser.add_argument("--test-login", action="store_true", help="Test login functionality") parser.add_argument("--show-config", action="store_true", help="Display loaded configuration") - parser.add_argument("--run", action="store_true", help="Run the scheduler (dry run by default)") - parser.add_argument("--live", action="store_true", help="Actually make reservations (use with --run or --test-book)") + parser.add_argument("--run", action="store_true", help="Run once and check for reservations (one-shot mode)") + parser.add_argument("--daemon", action="store_true", help="Run continuously as a daemon (recommended)") + parser.add_argument("--live", action="store_true", help="Actually make reservations (use with --run, --daemon, or --test-book)") parser.add_argument("--test-book", nargs=4, metavar=("COURT", "ACCOUNT", "DATE", "TIME"), help="Test booking: --test-book Court_1 Saleh 2026-01-19 19:00:00-08:00") @@ -741,15 +909,19 @@ def main(): bot.test_login() if args.test_book: - court, account, date, time = args.test_book + court, account, date, time_val = args.test_book dry_run = not args.live - bot.test_booking(court, account, date, time, dry_run=dry_run) + bot.test_booking(court, account, date, time_val, dry_run=dry_run) if args.run: dry_run = not args.live bot.run_scheduler(dry_run=dry_run) - if not any([args.show_config, args.test_login, args.run, args.test_book]): + if args.daemon: + dry_run = not args.live + bot.run_daemon(dry_run=dry_run) + + if not any([args.show_config, args.test_login, args.run, args.test_book, args.daemon]): parser.print_help() except FileNotFoundError as e: diff --git a/prompts.txt b/prompts.txt index 378b830..a176cd7 100644 --- a/prompts.txt +++ b/prompts.txt @@ -61,8 +61,9 @@ 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. @@ -74,4 +75,6 @@ curl 'https://future.ourclublogin.com/api/CustomerAuth/CustomerLogin' \ # Dry-Run python harbor_bot.py --test-book Court_1 Saleh 2026-01-19 "19:00:00-08:00" -python harbor_bot.py --test-book Court_1 Saleh 2026-01-19 "19:00:00-08:00" --live \ No newline at end of file +python harbor_bot.py --test-book Court_1 Saleh 2026-01-19 "19:00:00-08:00" --live + +python harbor_bot.py --daemon --live \ No newline at end of file