You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
harbor-bot/harbor_bot.py

1354 lines
55 KiB
Python

#!/usr/bin/env python3
"""
Harbor Isles Pickleball Court Reservation Bot
This bot automates court reservations using curl_cffi to mimic browser behavior.
"""
import json
import os
import sys
import time
import re
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
from pathlib import Path
from curl_cffi import requests
# Constants
TOKEN_FILE = "token_data.json"
SUCCESS_TRACKING_FILE = "reservation_success.json"
TOKEN_MAX_AGE_DAYS = 3
# Browser-like headers
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br, zstd",
"Content-Type": "application/json",
"If-Modified-Since": "Mon, 26 Jul 1997 05:00:00 GMT",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Priority": "u=0",
}
@dataclass
class Account:
name: str
id: int
@dataclass
class Court:
court: str
id: int
name: str
resource_type_id: int
assigned_resource_id: int
is_assigned_resource_selectable: bool
@dataclass
class AttemptSchedule:
time: str
attempts: int
delay_seconds: int
@dataclass
class ReservationBehavior:
name: str
days_in_advance: int
attempt_schedule: List[AttemptSchedule]
@dataclass
class CourtReservation:
court: str
account: str
@dataclass
class Reservation:
day: str
time: str
reservation_behavior: str
courts: List[CourtReservation]
@dataclass
class EmailConfig:
enabled: bool
smtp_server: str
smtp_port: int
smtp_username: str
smtp_password: str
from_address: str
to_addresses: List[str]
use_tls: bool = True
@dataclass
class Config:
base_api_url: str
company_id: str
club_id: int
appointment_item_id: int
login_username: str
login_password: str
logged_in_customer_id: int
accounts: Dict[str, Account]
courts: Dict[str, Court]
reservation_behaviors: Dict[str, ReservationBehavior]
reservations: List[Reservation]
email_config: Optional[EmailConfig] = None
@dataclass
class TokenData:
token: str
token_expiration: str
generated_at: str
def escape_password_for_curl_display(password: str) -> str:
"""Convert password for display in curl command (octal escape sequences).
Example: "Goliath00!!@" becomes "Goliath00\\041\\041@" for curl $'...' syntax.
Note: The API itself expects the raw password, not escaped.
"""
result = []
for char in password:
if char == '!':
result.append('\\041')
else:
result.append(char)
return ''.join(result)
def load_config(config_path: str = "config.json") -> Config:
"""Load and validate configuration from JSON file."""
if not os.path.exists(config_path):
raise FileNotFoundError(f"Configuration file not found: {config_path}")
with open(config_path, 'r') as f:
data = json.load(f)
# Validate required fields
required_fields = [
"BaseAPIUrl", "CompanyId", "ClubId", "AppointmentItemId",
"Login", "LoggedInCustomerId", "Accounts", "Courts",
"ReservationBehaviors", "Reservations"
]
for field_name in required_fields:
if field_name not in data:
raise ValueError(f"Missing required configuration field: {field_name}")
# Validate Login fields
if "Username" not in data["Login"] or "Password" not in data["Login"]:
raise ValueError("Login must contain Username and Password")
# Parse accounts
accounts = {}
for acc in data["Accounts"]:
if "Name" not in acc or "Id" not in acc:
raise ValueError(f"Invalid account configuration: {acc}")
accounts[acc["Name"]] = Account(name=acc["Name"], id=acc["Id"])
# Parse courts
courts = {}
for ct in data["Courts"]:
required_court_fields = ["Court", "Id", "Name", "ResourceTypeId", "AssignedResourceId", "IsAssignedResourceSelectable"]
for cf in required_court_fields:
if cf not in ct:
raise ValueError(f"Court missing required field '{cf}': {ct}")
courts[ct["Court"]] = Court(
court=ct["Court"],
id=ct["Id"],
name=ct["Name"],
resource_type_id=ct["ResourceTypeId"],
assigned_resource_id=ct["AssignedResourceId"],
is_assigned_resource_selectable=ct["IsAssignedResourceSelectable"]
)
# Parse reservation behaviors
behaviors = {}
for beh in data["ReservationBehaviors"]:
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 = []
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 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"],
delay_seconds=sched["DelaySeconds"]
))
behaviors[beh["Name"]] = ReservationBehavior(
name=beh["Name"],
days_in_advance=beh["DaysInAdvance"],
attempt_schedule=schedules
)
# Parse reservations
reservations = []
valid_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
for res in data["Reservations"]:
if "Day" not in res or "Time" not in res or "ReservationBehavior" not in res or "Courts" not in res:
raise ValueError(f"Invalid reservation configuration: {res}")
if res["Day"] not in valid_days:
raise ValueError(f"Invalid day '{res['Day']}'. Must be one of: {valid_days}")
if res["ReservationBehavior"] not in behaviors:
raise ValueError(f"Unknown reservation behavior: {res['ReservationBehavior']}")
court_reservations = []
for cr in res["Courts"]:
if "Court" not in cr or "Account" not in cr:
raise ValueError(f"Invalid court reservation: {cr}")
if cr["Court"] not in courts:
raise ValueError(f"Unknown court: {cr['Court']}")
if cr["Account"] not in accounts:
raise ValueError(f"Unknown account: {cr['Account']}")
court_reservations.append(CourtReservation(court=cr["Court"], account=cr["Account"]))
reservations.append(Reservation(
day=res["Day"],
time=res["Time"],
reservation_behavior=res["ReservationBehavior"],
courts=court_reservations
))
# Parse email configuration (optional)
email_config = None
if "Email" in data:
email_data = data["Email"]
if email_data.get("Enabled", False):
required_email_fields = ["SmtpServer", "SmtpPort", "SmtpUsername", "SmtpPassword", "FromAddress", "ToAddresses"]
for field_name in required_email_fields:
if field_name not in email_data:
raise ValueError(f"Email is enabled but missing required field: {field_name}")
if not isinstance(email_data["ToAddresses"], list) or len(email_data["ToAddresses"]) == 0:
raise ValueError("Email ToAddresses must be a non-empty list of email addresses")
email_config = EmailConfig(
enabled=True,
smtp_server=email_data["SmtpServer"],
smtp_port=email_data["SmtpPort"],
smtp_username=email_data["SmtpUsername"],
smtp_password=email_data["SmtpPassword"],
from_address=email_data["FromAddress"],
to_addresses=email_data["ToAddresses"],
use_tls=email_data.get("UseTLS", True)
)
return Config(
base_api_url=data["BaseAPIUrl"],
company_id=data["CompanyId"],
club_id=data["ClubId"],
appointment_item_id=data["AppointmentItemId"],
login_username=data["Login"]["Username"],
login_password=data["Login"]["Password"],
logged_in_customer_id=data["LoggedInCustomerId"],
accounts=accounts,
courts=courts,
reservation_behaviors=behaviors,
reservations=reservations,
email_config=email_config
)
class HarborBot:
"""Main bot class for handling pickleball court reservations."""
def __init__(self, config: Config, config_dir: str = "."):
self.config = config
self.config_dir = config_dir
self.token_file = os.path.join(config_dir, TOKEN_FILE)
self.success_file = os.path.join(config_dir, SUCCESS_TRACKING_FILE)
self.session = requests.Session(impersonate="firefox")
self.token_data: Optional[TokenData] = None
self._load_token()
self._load_success_tracking()
def _get_base_headers(self) -> Dict[str, str]:
"""Get base headers for requests."""
headers = DEFAULT_HEADERS.copy()
headers["x-companyid"] = self.config.company_id
headers["Origin"] = "https://future.ourclublogin.com"
headers["Referer"] = f"https://future.ourclublogin.com/login/{self.config.company_id}"
return headers
def _get_auth_headers(self) -> Dict[str, str]:
"""Get headers with authentication token."""
if not self.token_data:
raise RuntimeError("Not authenticated. Call login() first.")
headers = self._get_base_headers()
headers["Authorization"] = f"Bearer {self.token_data.token}"
headers["x-customerid"] = str(self.config.logged_in_customer_id)
headers["Referer"] = "https://future.ourclublogin.com/Appointments"
return headers
def _load_token(self):
"""Load token from file if it exists."""
if os.path.exists(self.token_file):
try:
with open(self.token_file, 'r') as f:
data = json.load(f)
self.token_data = TokenData(
token=data["token"],
token_expiration=data["token_expiration"],
generated_at=data["generated_at"]
)
print(f"[INFO] Loaded existing token from {self.token_file}")
except (json.JSONDecodeError, KeyError) as e:
print(f"[WARN] Failed to load token file: {e}")
self.token_data = None
def _save_token(self):
"""Save token to file."""
if self.token_data:
with open(self.token_file, 'w') as f:
json.dump({
"token": self.token_data.token,
"token_expiration": self.token_data.token_expiration,
"generated_at": self.token_data.generated_at
}, f, indent=2)
print(f"[INFO] Token saved to {self.token_file}")
def _load_success_tracking(self):
"""Load success tracking data."""
self.success_tracking: Dict[str, bool] = {}
if os.path.exists(self.success_file):
try:
with open(self.success_file, 'r') as f:
self.success_tracking = json.load(f)
except (json.JSONDecodeError, KeyError):
self.success_tracking = {}
def _save_success_tracking(self):
"""Save success tracking data."""
with open(self.success_file, 'w') as f:
json.dump(self.success_tracking, f, indent=2)
def _send_success_email(self, court: Court, account: Account, reservation_datetime: str):
"""
Send email notification for successful booking.
Args:
court: The court that was booked
account: The account the booking was made under
reservation_datetime: The datetime of the reservation
"""
if not self.config.email_config or not self.config.email_config.enabled:
return
email_cfg = self.config.email_config
# Parse the reservation datetime for display
try:
# Format: "2026-01-19T17:30:00-08:00"
dt_part = reservation_datetime[:19]
tz_part = reservation_datetime[19:]
dt = datetime.strptime(dt_part, "%Y-%m-%dT%H:%M:%S")
formatted_date = dt.strftime("%A, %B %d, %Y")
formatted_time = dt.strftime("%I:%M %p")
except:
formatted_date = reservation_datetime
formatted_time = ""
subject = f"✅ Court Booked: {court.name} on {formatted_date}"
body_text = f"""Harbor Bot - Successful Court Reservation
Court: {court.name}
Date: {formatted_date}
Time: {formatted_time} (Pacific)
Booked Under:
Name: {account.name}
Account ID: {account.id}
Reservation Details:
Full DateTime: {reservation_datetime}
Court ID: {court.id}
---
This is an automated message from Harbor Bot.
"""
body_html = f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #4CAF50; color: white; padding: 20px; text-align: center;">
<h1 style="margin: 0;"> Court Booked!</h1>
</div>
<div style="padding: 20px; background-color: #f9f9f9;">
<h2 style="color: #333; margin-top: 0;">{court.name}</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 10px; border-bottom: 1px solid #ddd;"><strong>Date:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #ddd;">{formatted_date}</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #ddd;"><strong>Time:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #ddd;">{formatted_time} (Pacific)</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #ddd;"><strong>Booked Under:</strong></td>
<td style="padding: 10px; border-bottom: 1px solid #ddd;">{account.name} (ID: {account.id})</td>
</tr>
</table>
</div>
<div style="padding: 10px; text-align: center; color: #666; font-size: 12px;">
This is an automated message from Harbor Bot.
</div>
</body>
</html>
"""
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = email_cfg.from_address
msg["To"] = ", ".join(email_cfg.to_addresses)
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
if email_cfg.use_tls:
server = smtplib.SMTP(email_cfg.smtp_server, email_cfg.smtp_port)
server.starttls()
else:
server = smtplib.SMTP_SSL(email_cfg.smtp_server, email_cfg.smtp_port)
server.login(email_cfg.smtp_username, email_cfg.smtp_password)
server.sendmail(email_cfg.from_address, email_cfg.to_addresses, msg.as_string())
server.quit()
print(f" [EMAIL] Notification sent to {len(email_cfg.to_addresses)} recipient(s)")
except Exception as e:
print(f" [EMAIL ERROR] Failed to send notification: {e}")
def _get_reservation_key(self, reservation_date: str, court: str, account: str) -> str:
"""Generate unique key for tracking reservation success."""
return f"{reservation_date}_{court}_{account}"
def _is_reservation_successful(self, reservation_date: str, court: str, account: str) -> bool:
"""Check if a reservation was already successful."""
key = self._get_reservation_key(reservation_date, court, account)
return self.success_tracking.get(key, False)
def _mark_reservation_successful(self, reservation_date: str, court: str, account: str):
"""Mark a reservation as successful."""
key = self._get_reservation_key(reservation_date, court, account)
self.success_tracking[key] = True
self._save_success_tracking()
def _needs_login(self) -> bool:
"""Check if login is needed (token expired or too old)."""
if not self.token_data:
return True
# Check if token is expired
try:
expiration = datetime.fromisoformat(self.token_data.token_expiration.replace('Z', '+00:00'))
if datetime.now(timezone.utc) >= expiration:
print("[INFO] Token expired")
return True
except ValueError:
print("[WARN] Could not parse token expiration")
return True
# Check if token is older than TOKEN_MAX_AGE_DAYS
try:
generated = datetime.fromisoformat(self.token_data.generated_at.replace('Z', '+00:00'))
age = datetime.now(timezone.utc) - generated
if age.days >= TOKEN_MAX_AGE_DAYS:
print(f"[INFO] Token is {age.days} days old, refreshing")
return True
except ValueError:
print("[WARN] Could not parse token generation time")
return True
return False
def login(self) -> bool:
"""Perform login to get authentication token."""
print("[INFO] Logging in...")
headers = self._get_base_headers()
headers["x-customerid"] = "0"
# API expects raw password - curl uses $'...' syntax with \041 for display
payload = {
"UserLogin": self.config.login_username,
"Pswd": self.config.login_password
}
url = f"{self.config.base_api_url}/CustomerAuth/CustomerLogin"
try:
response = self.session.post(
url,
headers=headers,
json=payload,
cookies={"coid": self.config.company_id}
)
response.raise_for_status()
data = response.json()
if data.get("LoginResult") != 1:
print(f"[ERROR] Login failed: {data.get('LoginError', 'Unknown error')}")
return False
# Extract token data
token_info = data.get("data", {})
if not token_info.get("token"):
print("[ERROR] No token in login response")
return False
self.token_data = TokenData(
token=token_info["token"],
token_expiration=token_info["tokenExpiration"],
generated_at=datetime.now(timezone.utc).isoformat()
)
self._save_token()
print(f"[INFO] Login successful. Customer: {data.get('CustomerName', {}).get('DisplayName', 'Unknown')}")
return True
except Exception as e:
print(f"[ERROR] Login request failed: {e}")
return False
def ensure_authenticated(self) -> bool:
"""Ensure we have a valid authentication token."""
if self._needs_login():
return self.login()
print("[INFO] Using existing valid token")
return True
def book_appointment(
self,
court: Court,
account: Account,
start_date: str
) -> Dict[str, Any]:
"""
Book a court appointment.
Args:
court: Court to book
account: Account to book under (PrimaryCustomerId)
start_date: ISO format datetime string (e.g., "2026-01-15T17:30:00-08:00")
Returns:
API response as dict
"""
if not self.ensure_authenticated():
return {"Success": False, "Message": "Authentication failed"}
headers = self._get_auth_headers()
payload = {
"ClubId": self.config.club_id,
"LoggedInCustomerId": self.config.logged_in_customer_id,
"PrimaryCustomerId": account.id,
"AdditionalCustomerIds": [],
"AppointmentItemId": self.config.appointment_item_id,
"SelectedBooks": [{
"Id": court.id,
"Name": court.name,
"ResourceTypeId": court.resource_type_id,
"AssignedResourceId": court.assigned_resource_id,
"IsAssignedResourceSelectable": court.is_assigned_resource_selectable
}],
"PackageItemId": 0,
"PackageQuantity": 0,
"ChangeFeeId": 0,
"StartDate": start_date,
"UserDisplayedPayNowGrandTotal": 0,
"DisplayedAmountDueAtTimeOfService": 0,
"CancellationAppointmentId": 0
}
url = f"{self.config.base_api_url}/TransactionProcessing/BookAppointmentOnAccount"
try:
response = self.session.post(
url,
headers=headers,
json=payload,
cookies={"coid": self.config.company_id}
)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"[ERROR] Book appointment request failed: {e}")
return {"Success": False, "Message": str(e)}
def calculate_reservation_date(self, day_name: str, days_in_advance: int) -> Optional[datetime]:
"""
Calculate the target reservation date if we should make it today.
For example: if days_in_advance=7 and day_name="Wednesday", we can only
book Wednesday's court on the Wednesday that is 7 days before it.
Returns the target date if today is the trigger day, None otherwise.
"""
day_mapping = {
"Monday": 0, "Tuesday": 1, "Wednesday": 2, "Thursday": 3,
"Friday": 4, "Saturday": 5, "Sunday": 6
}
target_day = day_mapping.get(day_name)
if target_day is None:
return None
today = datetime.now()
today_weekday = today.weekday()
# The target date is days_in_advance days from today
target_date = today + timedelta(days=days_in_advance)
# Check if that target date falls on the correct day of week
if target_date.weekday() == target_day:
return target_date
return None
def get_next_reservation_info(self, reservation: Reservation) -> Dict[str, Any]:
"""Get information about when a reservation would next trigger."""
behavior = self.config.reservation_behaviors[reservation.reservation_behavior]
day_mapping = {
"Monday": 0, "Tuesday": 1, "Wednesday": 2, "Thursday": 3,
"Friday": 4, "Saturday": 5, "Sunday": 6
}
target_day = day_mapping.get(reservation.day)
today = datetime.now()
today_weekday = today.weekday()
# Find next occurrence of target day that is days_in_advance away
for offset in range(14): # Check next 2 weeks
check_date = today + timedelta(days=offset)
target_date = check_date + timedelta(days=behavior.days_in_advance)
if target_date.weekday() == target_day:
return {
"trigger_date": check_date.strftime("%Y-%m-%d"),
"reservation_date": target_date.strftime("%Y-%m-%d"),
"days_until_trigger": offset
}
return {"error": "Could not calculate next reservation"}
8 months ago
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,
behavior: ReservationBehavior,
target_date: datetime,
dry_run: bool = True
):
"""
Execute reservation attempts according to the behavior schedule.
8 months ago
Waits for the scheduled times before making attempts.
Args:
reservation: The reservation configuration
behavior: The reservation behavior with attempt schedule
target_date: The target date for the reservation
dry_run: If True, just print what would happen instead of actually booking
"""
# Format the reservation datetime
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"\n[INFO] Reservation execution for {reservation.day} at {reservation.time}")
print(f"[INFO] Target date: {reservation_datetime}")
print(f"[INFO] Using behavior: {behavior.name}")
8 months ago
# 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:
8 months ago
# 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")
8 months ago
# 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]
# 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
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} at {reservation_datetime}")
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)
self._send_success_email(court, account, reservation_datetime)
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 dry_run:
print(f" [DRY RUN] Would wait {schedule.delay_seconds}s")
else:
time.sleep(schedule.delay_seconds)
8 months ago
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
# Print initial status showing next scheduled attempts
self._print_upcoming_reservations()
last_status_print = datetime.now()
8 months ago
while True:
try:
self._daemon_iteration(dry_run)
# 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)
8 months ago
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()
8 months ago
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})")
8 months ago
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()
# 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]
8 months ago
# 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")
8 months ago
continue
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)
self._send_success_email(court, account, reservation_datetime)
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)
8 months ago
# 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):
"""
8 months ago
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
"""
print(f"\n{'='*60}")
print(f"Harbor Bot Scheduler - {'DRY RUN' if dry_run else 'LIVE MODE'}")
print(f"{'='*60}")
if not self.ensure_authenticated():
print("[ERROR] Failed to authenticate. Exiting.")
return
print(f"\n[INFO] Checking {len(self.config.reservations)} configured reservations...")
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 target_date:
print(f"\n[TRIGGER] Reservation for {reservation.day} at {reservation.time}")
print(f"[INFO] Target date: {target_date.strftime('%Y-%m-%d')}")
self.execute_reservation_attempts(reservation, behavior, target_date, dry_run)
else:
print(f"\n[SKIP] {reservation.day} at {reservation.time} - not scheduled for today")
def test_login(self):
"""Test the login functionality."""
print("\n[TEST] Testing login...")
if self.login():
print("[TEST] Login successful!")
print(f"[TEST] Token expires: {self.token_data.token_expiration}")
else:
print("[TEST] Login failed!")
def show_config(self):
"""Display the loaded configuration."""
print("\n" + "="*60)
print("CONFIGURATION SUMMARY")
print("="*60)
print(f"\nAPI URL: {self.config.base_api_url}")
print(f"Company ID: {self.config.company_id}")
print(f"Club ID: {self.config.club_id}")
print(f"Login User: {self.config.login_username}")
print(f"\nAccounts ({len(self.config.accounts)}):")
for name, acc in self.config.accounts.items():
print(f" - {name}: ID={acc.id}")
print(f"\nCourts ({len(self.config.courts)}):")
for name, court in self.config.courts.items():
print(f" - {name}: {court.name} (ID={court.id})")
print(f"\nReservation Behaviors ({len(self.config.reservation_behaviors)}):")
for name, beh in self.config.reservation_behaviors.items():
print(f" - {name}: {beh.days_in_advance} days in advance")
for sched in beh.attempt_schedule:
print(f" @ {sched.time}: {sched.attempts} attempts, {sched.delay_seconds}s delay")
print(f"\nReservations ({len(self.config.reservations)}):")
for res in self.config.reservations:
next_info = self.get_next_reservation_info(res)
trigger_info = ""
if "trigger_date" in next_info:
if next_info["days_until_trigger"] == 0:
trigger_info = f" [TRIGGERS TODAY for {next_info['reservation_date']}]"
else:
trigger_info = f" [Next trigger: {next_info['trigger_date']} for {next_info['reservation_date']}]"
print(f" - {res.day} @ {res.time} ({res.reservation_behavior}){trigger_info}")
for cr in res.courts:
print(f" {cr.court} -> {cr.account}")
print(f"\nEmail Notifications:")
if self.config.email_config and self.config.email_config.enabled:
print(f" Status: ENABLED")
print(f" SMTP Server: {self.config.email_config.smtp_server}:{self.config.email_config.smtp_port}")
print(f" From: {self.config.email_config.from_address}")
print(f" To: {', '.join(self.config.email_config.to_addresses)}")
else:
print(f" Status: DISABLED")
def test_booking(self, court_name: str, account_name: str, date_str: str, time_str: str, dry_run: bool = True):
"""
Test booking a specific court for a specific account.
Args:
court_name: Court identifier (e.g., "Court_1")
account_name: Account name (e.g., "Saleh")
date_str: Date in YYYY-MM-DD format
time_str: Time with timezone (e.g., "19:00:00-08:00")
dry_run: If True, just print what would happen
"""
if court_name not in self.config.courts:
print(f"[ERROR] Unknown court: {court_name}")
print(f"[INFO] Available courts: {', '.join(self.config.courts.keys())}")
return
if account_name not in self.config.accounts:
print(f"[ERROR] Unknown account: {account_name}")
print(f"[INFO] Available accounts: {', '.join(self.config.accounts.keys())}")
return
court = self.config.courts[court_name]
account = self.config.accounts[account_name]
reservation_datetime = f"{date_str}T{time_str}"
print(f"\n[TEST BOOKING] {'DRY RUN' if dry_run else 'LIVE'}")
print(f" Court: {court.name} (ID={court.id})")
print(f" Account: {account.name} (ID={account.id})")
print(f" DateTime: {reservation_datetime}")
if dry_run:
print(f"\n[DRY RUN] Would book {court.name} for {account.name} at {reservation_datetime}")
else:
if not self.ensure_authenticated():
print("[ERROR] Authentication failed")
return
result = self.book_appointment(court, account, reservation_datetime)
if result.get("Success"):
print(f"\n[SUCCESS] Booked {court.name} for {account.name}")
self._send_success_email(court, account, reservation_datetime)
else:
print(f"\n[FAILED] {result.get('Message', 'Unknown error')}")
print(f"[DEBUG] Full response: {json.dumps(result, indent=2)}")
def test_email(self):
"""Send a test email to verify email configuration."""
if not self.config.email_config:
print("[ERROR] No email configuration found in config.json")
print("[INFO] Add an 'Email' section to your config.json file")
return False
if not self.config.email_config.enabled:
print("[ERROR] Email is disabled in configuration")
print("[INFO] Set 'Enabled': true in the Email section of config.json")
return False
email_cfg = self.config.email_config
print(f"\n[TEST EMAIL] Sending test email...")
print(f" SMTP Server: {email_cfg.smtp_server}:{email_cfg.smtp_port}")
print(f" From: {email_cfg.from_address}")
print(f" To: {', '.join(email_cfg.to_addresses)}")
print(f" TLS: {email_cfg.use_tls}")
# Create test email content
subject = "🏓 Harbor Bot - Test Email"
body_text = f"""Harbor Bot - Email Test
This is a test email from Harbor Bot to verify your email configuration is working correctly.
Configuration:
SMTP Server: {email_cfg.smtp_server}:{email_cfg.smtp_port}
From: {email_cfg.from_address}
TLS Enabled: {email_cfg.use_tls}
If you received this email, your configuration is correct!
---
Sent at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
"""
body_html = f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #2196F3; color: white; padding: 20px; text-align: center;">
<h1 style="margin: 0;">🏓 Harbor Bot</h1>
<p style="margin: 10px 0 0 0;">Email Test</p>
</div>
<div style="padding: 20px; background-color: #f9f9f9;">
<p>This is a test email from Harbor Bot to verify your email configuration is working correctly.</p>
<h3 style="color: #333;">Configuration</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>SMTP Server:</strong></td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">{email_cfg.smtp_server}:{email_cfg.smtp_port}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>From:</strong></td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">{email_cfg.from_address}</td>
</tr>
<tr>
<td style="padding: 8px; border-bottom: 1px solid #ddd;"><strong>TLS Enabled:</strong></td>
<td style="padding: 8px; border-bottom: 1px solid #ddd;">{email_cfg.use_tls}</td>
</tr>
</table>
<p style="margin-top: 20px; padding: 15px; background-color: #e8f5e9; border-radius: 5px; color: #2e7d32;">
If you received this email, your configuration is correct!
</p>
</div>
<div style="padding: 10px; text-align: center; color: #666; font-size: 12px;">
Sent at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
</div>
</body>
</html>
"""
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = email_cfg.from_address
msg["To"] = ", ".join(email_cfg.to_addresses)
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
print(f"\n[INFO] Connecting to SMTP server...")
if email_cfg.use_tls:
server = smtplib.SMTP(email_cfg.smtp_server, email_cfg.smtp_port)
server.set_debuglevel(1) # Enable debug output
print(f"[INFO] Starting TLS...")
server.starttls()
else:
server = smtplib.SMTP_SSL(email_cfg.smtp_server, email_cfg.smtp_port)
server.set_debuglevel(1)
print(f"[INFO] Logging in as {email_cfg.smtp_username}...")
server.login(email_cfg.smtp_username, email_cfg.smtp_password)
print(f"[INFO] Sending email...")
server.sendmail(email_cfg.from_address, email_cfg.to_addresses, msg.as_string())
server.quit()
print(f"\n[SUCCESS] Test email sent successfully!")
print(f"[INFO] Check inbox of: {', '.join(email_cfg.to_addresses)}")
return True
except smtplib.SMTPAuthenticationError as e:
print(f"\n[ERROR] Authentication failed: {e}")
print(f"\n[HELP] For Gmail:")
print(f" 1. Enable 2-factor authentication on your Google account")
print(f" 2. Go to: https://myaccount.google.com/apppasswords")
print(f" 3. Generate an App Password for 'Mail'")
print(f" 4. Use the 16-character app password (no spaces) as SmtpPassword")
return False
except smtplib.SMTPException as e:
print(f"\n[ERROR] SMTP error: {e}")
return False
except Exception as e:
print(f"\n[ERROR] Failed to send test email: {e}")
return False
def main():
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="Harbor Isles Pickleball Court Reservation Bot")
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("--test-email", action="store_true", help="Send a test email to verify email configuration")
parser.add_argument("--show-config", action="store_true", help="Display loaded configuration")
8 months ago
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")
args = parser.parse_args()
# Determine config directory
config_path = Path(args.config)
config_dir = str(config_path.parent) if config_path.parent != Path(".") else "."
try:
print(f"[INFO] Loading configuration from {args.config}...")
config = load_config(args.config)
print("[INFO] Configuration loaded successfully!")
bot = HarborBot(config, config_dir)
if args.show_config:
bot.show_config()
if args.test_login:
bot.test_login()
if args.test_email:
bot.test_email()
if args.test_book:
8 months ago
court, account, date, time_val = args.test_book
dry_run = not args.live
8 months ago
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)
8 months ago
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.test_email, args.run, args.test_book, args.daemon]):
parser.print_help()
except FileNotFoundError as e:
print(f"[ERROR] {e}")
sys.exit(1)
except ValueError as e:
print(f"[ERROR] Configuration error: {e}")
sys.exit(1)
except Exception as e:
print(f"[ERROR] Unexpected error: {e}")
raise
if __name__ == "__main__":
main()