|
|
|
|
@ -10,6 +10,9 @@ 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
|
|
|
|
|
@ -84,6 +87,18 @@ class Reservation:
|
|
|
|
|
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
|
|
|
|
|
@ -97,6 +112,7 @@ class Config:
|
|
|
|
|
courts: Dict[str, Court]
|
|
|
|
|
reservation_behaviors: Dict[str, ReservationBehavior]
|
|
|
|
|
reservations: List[Reservation]
|
|
|
|
|
email_config: Optional[EmailConfig] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
@ -257,6 +273,30 @@ def load_config(config_path: str = "config.json") -> Config:
|
|
|
|
|
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"],
|
|
|
|
|
@ -268,7 +308,8 @@ def load_config(config_path: str = "config.json") -> Config:
|
|
|
|
|
accounts=accounts,
|
|
|
|
|
courts=courts,
|
|
|
|
|
reservation_behaviors=behaviors,
|
|
|
|
|
reservations=reservations
|
|
|
|
|
reservations=reservations,
|
|
|
|
|
email_config=email_config
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -345,6 +386,106 @@ class HarborBot:
|
|
|
|
|
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}"
|
|
|
|
|
@ -690,6 +831,7 @@ class HarborBot:
|
|
|
|
|
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')}")
|
|
|
|
|
@ -871,6 +1013,7 @@ class HarborBot:
|
|
|
|
|
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')}")
|
|
|
|
|
@ -964,6 +1107,15 @@ class HarborBot:
|
|
|
|
|
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.
|
|
|
|
|
@ -1005,11 +1157,133 @@ class HarborBot:
|
|
|
|
|
|
|
|
|
|
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."""
|
|
|
|
|
@ -1018,6 +1292,7 @@ def main():
|
|
|
|
|
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")
|
|
|
|
|
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)")
|
|
|
|
|
@ -1044,6 +1319,9 @@ def main():
|
|
|
|
|
if args.test_login:
|
|
|
|
|
bot.test_login()
|
|
|
|
|
|
|
|
|
|
if args.test_email:
|
|
|
|
|
bot.test_email()
|
|
|
|
|
|
|
|
|
|
if args.test_book:
|
|
|
|
|
court, account, date, time_val = args.test_book
|
|
|
|
|
dry_run = not args.live
|
|
|
|
|
@ -1057,7 +1335,7 @@ def main():
|
|
|
|
|
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]):
|
|
|
|
|
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:
|
|
|
|
|
|