Skill: elizabethsiegle/bayclub-gateway-booking (version 1.0.0)

--- name: bayclub_manager description: "Book and manage tennis/pickleball courts at Bay Club." user-invocable: true metadata: { "openclaw": { "emoji": "🎾", "requires": { "bins": ["node"] }, "category": "Utilities" } } --- # Bay Club Manager This skill uses Stagehand and TypeScript to automate browser bookings. ## Instructions When the user asks to book or check courts: 1. Use the `shell` tool to run the implementation script. 2. The command to run is: `NODE_ENV=development STAGEHAND_ENV=LOCAL HEADLESS=true npx ts-node --transpile-only {baseDir}/bayclub_skills.ts` 3. Pass user arguments (date, time, club) as strings to the script. ## Files - Logic: `{baseDir}/bayclub_skills.ts` - Browser Engine: `{baseDir}/BayClubBot.ts`

path: BayClubBot.ts
content: import { Stagehand, Page } from '@browserbasehq/stagehand';

export type Sport = 'tennis' | 'pickleball';

export interface TimeSlot {
  time: string;
  available: boolean;
}

export class BayClubBot {
  private stagehand: Stagehand | null = null;
  private page: Page | null = null;
  private isLoggedIn = false;
  private username: string;
  private [REDACTED_SECRET];

  constructor(username: string, [REDACTED_SECRET]) {
    this.username = username;
    this.[REDACTED_SECRET];
  }

  /**
   * Initialize the browser
   */
  async init() {
    const isProduction = process.env.NODE_ENV === 'production';
    
    this.stagehand = new Stagehand({
      env: 'LOCAL',
      verbose: 1,
      localBrowserLaunchOptions: {
        headless: true,
        args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
      },
      // Browserbase credentials (required for production)
      ...(isProduction && {
        [REDACTED_SECRET],
        projectId: process.env.BROWSERBASE_PROJECT_ID,
      }),
    });

    await this.stagehand.init();
    this.page = this.stagehand.context.pages()[0];
  }

  /**
   * Login to Bay Club Connect
   */
  async login() {
    if (!this.page) {
      throw new Error('Stagehand not initialized. Call init() first.');
    }

    try {
      console.log('Navigating to Bay Club Connect...');
      await this.page.goto('https://bayclubconnect.com/home/dashboard', {
        waitUntil: 'domcontentloaded',
        timeout: 30000
      });

      console.log('Page loaded, checking current URL...');
      await this.page.waitForTimeout(3000);

      // Check if we're already logged in
      const isDashboard = this.page.url().includes('/home/dashboard');
      if (isDashboard) {
        try {
          await this.page.waitForSelector('text=Select Activity', { timeout: 3000 });
          console.log('Already logged in!');
          this.isLoggedIn = true;
          return;
        } catch (e) {
          console.log('Not logged in yet, proceeding with login...');
        }
      }

      console.log('Looking for login form...');
      const usernameField = this.page.locator('input[type="email"], input[type="text"]');
      
      await this.page.waitForSelector('input[type="email"], input[type="text"]', { timeout: 10000 });
      await usernameField.fill(this.username);

      const passwordField = this.page.locator('input[type="password"]');
      await passwordField.fill(this.password);

      // Click the submit button using evaluate to find it dynamically
      await this.page.evaluate(() => {
        const button = document.querySelector('button[type="submit"]') || 
                       Array.from(document.querySelectorAll('button')).find(b => 
                         b.textContent.toLowerCase().includes('log in') || 
                         b.textContent.toLowerCase().includes('sign in')
                       );
        if (button) button.click();
      });

      console.log('Waiting for navigation after login...');
      await this.page.waitForTimeout(5000);

      this.isLoggedIn = true;
      console.log('Successfully logged in!');
    } catch (error) {
      console.error('Login error:', error);
      throw new Error(`Failed to login: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }
  }

  /**
   * Navigate to the booking page for a specific sport and day
   */
  async navigateToBooking(sport: Sport, day?: string) {
    if (!this.page || !this.isLoggedIn) {
      throw new Error('Must be logged in first. Call login().');
    }

    try {
      console.log(`Navigating to ${sport} booking...`);
      await this.page.goto('https://bayclubconnect.com/home/dashboard', {
        waitUntil: 'networkidle',
        timeout: 30000
      });
      await this.page.waitForTimeout(3000);

      // Club Selection (Gateway)
      const clubSelector = 'xpath=/html/body/app-root/div/app-dashboard/div/div/div[1]/div[1]/app-club-context-select/div/span[4]';
      await this.page.locator(clubSelector).click();
      
      const gatewayClub = 'xpath=/html/body/modal-container/div[2]/div/app-club-context-select-modal/div[2]/div/app-schedule-visit-club/div/div[1]/div/div[2]/div/div[3]/div[1]/div/div[2]/app-radio-select/div/div[2]/div/div[2]/div/span';
      await this.page.waitForSelector(gatewayClub);
      await this.page.locator(gatewayClub).click();

      const saveButton = 'xpath=/html/body/modal-container/div[2]/div/app-club-context-select-modal/div[2]/div/app-schedule-visit-club/div/div[2]/div/div';
      await this.page.locator(saveButton).click();
      await this.page.waitForTimeout(2000);

      // Navigate to Court Booking
      const scheduleActivity = 'xpath=/html/body/app-root/div/app-navbar/nav/div/div/button/span';
      await this.page.locator(scheduleActivity).click();
      
      const courtBooking = 'xpath=/html/body/app-root/div/app-schedule-visit/div/div/div[2]/div[1]/div[2]/div/div/img';
      await this.page.waitForSelector(courtBooking);
      await this.page.locator(courtBooking).click();
      await this.page.waitForTimeout(5000);

      // Sport Selection - use xpath for reliability
      console.log(`Selecting ${sport}...`);
      await this.page.waitForTimeout(2000);
      
      const sportXpaths = {
        tennis: 'xpath=/html/body/app-root/div/ng-component/app-racquet-sports-filter/div[1]/div[1]/div/div/app-court-booking-category-select/div/div[1]/div/div[2]',
        pickleball: 'xpath=/html/body/app-root/div/ng-component/app-racquet-sports-filter/div[1]/div[1]/div/div/app-court-booking-category-select/div/div[2]/div/div[2]'
      };
      
      try {
        await this.page.locator(sportXpaths[sport]).click({ timeout: 5000 });
        console.log(`✓ Selected ${sport} via xpath`);
      } catch (e) {
        console.warn(`Xpath failed for ${sport}, trying text-based...`);
        // Fallback to text-based
        const clicked = await this.page.evaluate((sportName: string) => {
          const allDivs = Array.from(document.querySelectorAll('div'));
          const sportEl = allDivs.find(el => {
            const text = el.textContent?.trim() || '';
            const hasNoChildren = el.children.length === 0;
            return hasNoChildren && text.toLowerCase() === sportName.toLowerCase();
          });
          
          if (sportEl) {
            const parent = sportEl.parentElement;
            if (parent) parent.click();
            (sportEl as HTMLElement).click();
            return true;
          }
          return false;
        }, sport);
        
        if (!clicked) {
          throw new Error(`Failed to select ${sport}`);
        }
      }
      
      await this.page.waitForTimeout(3000);

      // Duration Selection - different for each sport
      if (sport === 'tennis') {
        console.log('Selecting tennis duration (1.5 hours)...');
        await this.page.waitForTimeout(2000);
        
        const tennisXpath = 'xpath=/html/body/app-root/div/ng-component/app-racquet-sports-filter/div[1]/div[2]/div[2]/app-button-select/div/div[3]/span';
        try {
          await this.page.locator(tennisXpath).click({ timeout: 5000 });
          console.log('✓ Selected 1.5 hour duration');
        } catch (e) {
          console.warn('Could not select tennis duration via xpath');
        }
      } else {
        // Pickleball - try to find and click 60 minute option
        console.log('Selecting pickleball duration (60 minutes)...');
        await this.page.waitForTimeout(2000);
        
        const pickleballXpath = 'xpath=/html/body/app-root/div/ng-component/app-racquet-sports-filter/div[1]/div[2]/div[2]/app-button-select/div/div[2]/span';
        try {
          await this.page.locator(pickleballXpath).click({ timeout: 5000 });
          console.log('✓ Selected 60 minute duration');
        } catch (e) {
          console.warn('Pickleball duration xpath failed, checking if duration selector exists...');
          const exists = await this.page.evaluate(() => !!document.querySelector('app-button-select'));
          console.log('Duration selector exists:', exists);
        }
      }
      
      await this.page.waitForTimeout(1000);

      // Click Next button to proceed
      console.log('Clicking Next to continue...');
      const nextButton = 'xpath=/html/body/app-root/div/ng-component/app-racquet-sports-filter/div[2]/app-racquet-sports-reservation-summary/div/div/div/div/button';
      await this.page.locator(nextButton).click();
      await this.page.waitForTimeout(3000);

      // Day Selection
      const dayMap: { [key: string]: string } = {
        'monday': 'Mo', 'tuesday': 'Tu', 'wednesday': 'We', 'thursday': 'Th',
        'friday': 'Fr', 'saturday': 'Sa', 'sunday': 'Su'
      };
      const dayAbbrev = day ? dayMap[day.toLowerCase()] || 'Mo' : 'Mo';
      
      // Find and click the day element using text matching - click first match
      const dayXpath = `xpath=//*[text()="${dayAbbrev}"]`;
      const dayLocator = this.page.locator(dayXpath);
      await dayLocator.nth(0).click();

      await this.page.waitForTimeout(2000);

      // Switch to Hour View
      const hourViewSelector = 'xpath=//span[contains(text(), "HOUR VIEW")]';
      try {
        await this.page.waitForSelector(hourViewSelector, { timeout: 5000 });
        
        // Try multiple methods to click
        try {
          await this.page.locator(hourViewSelector).click();
          console.log('Clicked Hour View button via selector');
        } catch (e1) {
          // Try with evaluate
          await this.page.evaluate(() => {
            const buttons = Array.from(document.querySelectorAll('span, button, div'));
            const hourViewBtn = buttons.find(b => b.textContent?.includes('HOUR VIEW'));
            if (hourViewBtn) {
              (hourViewBtn as HTMLElement).click();
              return true;
            }
            throw new Error('Hour View button not found');
          });
          console.log('Clicked Hour View button via evaluate');
        }
        
        await this.page.waitForTimeout(3000);
      } catch (e) {
        console.warn('Could not click Hour View, might already be active.');
      }
    } catch (error) {
      console.error('Navigation error:', error);
      throw error;
    }
  }

  /**
   * Get available time slots (filtered by booking duration)
   * Tennis: 1.5 hour (90 min) blocks
   * Pickleball: 1 hour (60 min) blocks
   * Returns slots as "START - END" format
   */
  async getAvailableTimes(sport: Sport = 'tennis'): Promise<string[]> {
    if (!this.page) throw new Error('Stagehand not initialized.');
    
    const durationMinutes = sport === 'tennis' ? 90 : 60;
    
    await this.page.waitForTimeout(5000);
    
    // Map grid positions to find which times have availability
    const times = await this.page.evaluate((duration: number) => {
      // 1. Get all time labels from the page
      const timeElements = Array.from(document.querySelectorAll('*')).filter(el => {
        const text = el.textContent?.trim() || '';
        return /^\d{1,2}:\d{2}\s*[AP]M$/.test(text) && el.children.length === 0;
      });
      
      // 2. Get their vertical positions
      const timePositions = timeElements.map(el => ({
        time: el.textContent?.trim() || '',
        top: el.getBoundingClientRect().top,
      }));
      
      // 3. Get all available (non-unavailable) time slots in the grid
      const allSlots = Array.from(document.querySelectorAll('.booking-calendar-column-time-slot'));
      const availableSlots = allSlots.filter(slot => 
        !slot.classList.contains('booking-calendar-column-time-slot-unavailable')
      );
      
      // 4. Get positions of available slots
      const availablePositions = availableSlots.map(slot => ({
        top: slot.getBoundingClientRect().top,
        height: slot.getBoundingClientRect().height,
      }));
      
      // 5. Match available slot positions to time labels
      const matchedTimes: string[] = [];
      
      for (const pos of availablePositions) {
        // Find closest time label
        let closestTime = '';
        let minDist = Infinity;
        
        for (const timePos of timePositions) {
          const dist = Math.abs(timePos.top - pos.top);
          if (dist < minDist) {
            minDist = dist;
            closestTime = timePos.time;
          }
        }
        
        // Only add if close enough and not already added
        if (closestTime && minDist < 50 && !matchedTimes.includes(closestTime)) {
          matchedTimes.push(closestTime);
        }
      }
      
      // Helper to parse time
      const parseTime = (t: string) => {
        const [time, period] = t.split(' ');
        const [hours, mins] = time.split(':').map(Number);
        let h = hours;
        if (period === 'PM' && h !== 12) h += 12;
        if (period === 'AM' && h === 12) h = 0;
        return h * 60 + mins;
      };
      
      // Helper to add minutes to a time string
      const addMinutes = (timeStr: string, minutesToAdd: number): string => {
        const mins = parseTime(timeStr);
        const newMins = mins + minutesToAdd;
        const hours = Math.floor(newMins / 60);
        const minutes = newMins % 60;
        
        let h = hours % 12;
        if (h === 0) h = 12;
        const period = hours >= 12 ? 'PM' : 'AM';
        
        return `${h}:${minutes.toString().padStart(2, '0')} ${period}`;
      };
      
      // Sort chronologically
      const sortedTimes = matchedTimes.sort((a, b) => parseTime(a) - parseTime(b));
      
      // Filter based on duration requirements passed in
      const slotsNeeded = duration / 30; // Number of 30-min slots needed
      
      const filteredSlots: string[] = [];
      
      for (let i = 0; i < sortedTimes.length; i++) {
        const startTime = sortedTimes[i];
        const startMins = parseTime(startTime);
        
        // Check if we have enough consecutive 30-minute slots
        let hasEnoughTime = true;
        for (let offset = 30; offset < duration; offset += 30) {
          const targetMins = startMins + offset;
          const hasSlot = sortedTimes.some(t => Math.abs(parseTime(t) - targetMins) < 5);
          if (!hasSlot) {
            hasEnoughTime = false;
            break;
          }
        }
        
        if (hasEnoughTime) {
          // Format as "START - END"
          const endTime = addMinutes(startTime, duration);
          filteredSlots.push(`${startTime} - ${endTime}`);
        }
      }
      
      return filteredSlots;
    }, durationMinutes);
    
    return times as string[];
  }

  /**
   * Book a court at the specified time
   */
  async bookCourt(time: string): Promise<boolean> {
    if (!this.page) throw new Error('Stagehand not initialized.');

    try {
      console.log(`Looking for bookable slot starting at ${time}...`);
      
      // Ensure we're in Hour View where .clickable.time-slot elements appear
      console.log('Ensuring Hour View is active...');
      await this.page.evaluate(() => {
        const all = Array.from(document.querySelectorAll('*'));
        const hourView = all.find(el => el.textContent?.trim() === 'HOUR VIEW');
        if (hourView) {
          (hourView as HTMLElement).click();
        }
      });
      
      await this.page.waitForTimeout(3000);
      
      // Find and click the time slot that starts with our target time
      const slotClicked = await this.page.evaluate((targetTime: string) => {
        const timeSlots = Array.from(document.querySelectorAll('.clickable.time-slot'));
        
        // Extract just the time part (e.g., "6:00 PM" → "6:00")
        const targetTimePart = targetTime.split(' ')[0]; // "6:00"
        
        for (const slot of timeSlots) {
          const lowercaseEl = slot.querySelector('.text-lowercase');
          if (lowercaseEl) {
            const text = lowercaseEl.textContent?.trim() || '';
            // Format is like "6:00  - 7:30 PM" or "7:00  - 8:30 AM"
            // Check if it starts with our target time
            if (text.startsWith(targetTimePart)) {
              (slot as HTMLElement).scrollIntoView({ block: 'center' });
              (slot as HTMLElement).click();
              console.log('Clicked time slot:', text);
              return true;
            }
          }
        }
        
        console.error('Could not find time slot for', targetTime);
        console.error('Available slots:', timeSlots.length);
        return false;
      }, time);

      if (!slotClicked) {
        console.error('Failed to click time slot');
        return false;
      }

      await this.page.waitForTimeout(2000);

      // Click Next/Continue button
      console.log('Looking for Next button...');
      try {
        const nextButton = this.page.locator('button:has-text("Next"), button:has-text("Continue")');
        await nextButton.click({ timeout: 5000 });
        console.log('Clicked Next button');
      } catch (e) {
        console.warn('Could not find Next button, trying xpath...');
        const nextXpath = 'xpath=/html/body/app-root/div/ng-component/app-racquet-sports-time-slot-select/div[2]/app-racquet-sports-reservation-summary/div/div/div/div[2]/button';
        await this.page.locator(nextXpath).click();
      }

      await this.page.waitForTimeout(3000);

      // Select buddy/partner - look for Samuel Wang or Emma Campbell
      console.log('Looking for buddy selection...');
      try {
        await this.page.waitForTimeout(3000);
        
        // Try to find and click Samuel Wang or Emma Campbell by name
        const buddySelected = await this.page.evaluate(() => {
          // Find all elements that might contain buddy/person info
          const allElements = Array.from(document.querySelectorAll('*'));
          
          // Look for Samuel Wang or Emma Campbell
          const samuelOrEmma = allElements.filter(el => {
            const text = el.textContent || '';
            return text.includes('Samuel Wang') || text.includes('Emma Campbell');
          });
          
          // Click the most specific (smallest) element
          if (samuelOrEmma.length > 0) {
            // Sort by text length to get most specific
            samuelOrEmma.sort((a, b) => 
              (a.textContent?.length || 999) - (b.textContent?.length || 999)
            );
            
            const buddy = samuelOrEmma[0] as HTMLElement;
            buddy.scrollIntoView({ block: 'center' });
            buddy.click();
            console.log('Selected buddy:', buddy.textContent?.trim().substring(0, 50));
            return true;
          }
          
          // Fallback: click any app-racquet-sports-person element
          const personElements = Array.from(document.querySelectorAll('app-racquet-sports-person'));
          if (personElements.length > 0) {
            (personElements[0] as HTMLElement).click();
            console.log('Selected first person element');
            return true;
          }
          
          console.log('No buddy elements found');
          return false;
        });
        
        if (buddySelected) {
          console.log('✓ Buddy selected');
          await this.page.waitForTimeout(2000);
        } else {
          console.log('⚠ No buddy selection made');
        }
      } catch (e) {
        console.log('Buddy selection error:', e);
      }
      
      // Step before confirming: Check for any required checkboxes (terms, policies, etc.)
      console.log('Checking for required checkboxes...');
      await this.page.evaluate(() => {
        const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"]'));
        checkboxes.forEach(cb => {
          if (!(cb as HTMLInputElement).checked) {
            (cb as HTMLInputElement).click();
            console.log('Checked a checkbox');
          }
        });
      });
      
      await this.page.waitForTimeout(1000);
      
      // Click Final Confirm Booking button
      console.log('Looking for Confirm Booking button...');
      try {
        // Look for "CONFIRM BOOKING" button specifically
        const confirmButton = this.page.locator('button:has-text("CONFIRM BOOKING")').first();
        
        // Wait a bit for it to become enabled after buddy selection
        await this.page.waitForTimeout(2000);
        
        await confirmButton.click({ timeout: 5000 });
        console.log('Clicked Confirm Booking - booking should be complete!');
      } catch (e) {
        console.error('Confirm button error:', e);
        // Try clicking any enabled Confirm button
        const clicked = await this.page.evaluate(() => {
          const buttons = Array.from(document.querySelectorAll('button'));
          const confirmBtn = buttons.find(b => 
            b.textContent?.includes('CONFIRM') && 
            b.textContent?.includes('BOOKING') &&
            !(b as HTMLButtonElement).disabled
          );
          if (confirmBtn) {
            (confirmBtn as HTMLElement).click();
            console.log('Clicked enabled confirm button via evaluate');
            return true;
          } else {
            console.error('All confirm buttons are still disabled');
            return false;
          }
        });
        
        if (!clicked) {
          throw new Error('Could not click Confirm button - it may still be disabled');
        }
      }
      
      await this.page.waitForTimeout(3000);
      
      return true;
    } catch (error) {
      console.error('Booking error:', error);
      return false;
    }
  }

  async close() {
    if (this.stagehand) {
      await this.stagehand.close();
      this.isLoggedIn = false;
    }
  }
}


path: GoogleCalendarService.js
content: const { google } = require('googleapis');
const { JWT } = require('google-auth-library');
const { readFileSync, existsSync } = require('fs');
const { resolve } = require('path');

class GoogleCalendarService {
  constructor() {
    this.calendar = null;
    this.initialized = false;
  }

  /**
   * Initialize Google Calendar API service
   */
  async init() {
    try {
      // Check for credentials file or environment variable
      const credentialsPath = process.env.GOOGLE_CALENDAR_CREDENTIALS_PATH 
        || resolve(__dirname, 'google-calendar-credentials.json');
      
      const credentialsJson = process.env.GOOGLE_CALENDAR_CREDENTIALS;

      let credentials;

      if (credentialsJson) {
        // Use credentials from environment variable (for production)
        credentials = JSON.parse(credentialsJson);
        console.log('[Calendar] Using credentials from environment variable');
      } else if (existsSync(credentialsPath)) {
        // Use credentials file (for local development)
        credentials = JSON.parse(readFileSync(credentialsPath, 'utf-8'));
        console.log('[Calendar] Using credentials from file:', credentialsPath);
      } else {
        console.warn('[Calendar] No credentials found, calendar integration disabled');
        return;
      }

      const auth = new JWT({
        email: credentials.client_email,
        key: credentials.private_key,
        scopes: ['https://www.googleapis.com/auth/calendar'],
      });

      this.calendar = google.calendar({ version: 'v3', auth });
      this.initialized = true;
      console.log('[Calendar] Service initialized successfully');
    } catch (error) {
      console.error('[Calendar] Failed to initialize:', error);
    }
  }

  /**
   * Check if calendar service is available
   */
  isAvailable() {
    return this.initialized && this.calendar !== null;
  }

  /**
   * Add an event to Google Calendar
   */
  async addEvent(event) {
    if (!this.calendar) {
      console.warn('[Calendar] Service not available, skipping event creation');
      return false;
    }

    try {
      console.log('[Calendar] Adding event:', event.summary);
      console.log('[Calendar] Start:', event.startDateTime.toISOString());
      console.log('[Calendar] End:', event.endDateTime.toISOString());

      const calendarId = process.env.GOOGLE_CALENDAR_ID || 'primary';

      // Format datetime as local time string without timezone (YYYY-MM-DDTHH:MM:SS)
      // Google will interpret this in the specified timeZone
      const formatLocalDateTime = (date, hours, minutes) => {
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        const h = String(hours).padStart(2, '0');
        const m = String(minutes).padStart(2, '0');
        return `${year}-${month}-${day}T${h}:${m}:00`;
      };

      // Extract the intended local time from the Date objects
      // (these were set with setHours in parseTimeToDate)
      const startLocal = formatLocalDateTime(
        event.startDateTime,
        event.startDateTime.getHours(),
        event.startDateTime.getMinutes()
      );
      const endLocal = formatLocalDateTime(
        event.endDateTime,
        event.endDateTime.getHours(),
        event.endDateTime.getMinutes()
      );

      console.log('[Calendar] Local start:', startLocal);
      console.log('[Calendar] Local end:', endLocal);

      const calendarEvent = {
        summary: event.summary,
        location: event.location,
        description: event.description,
        start: {
          dateTime: startLocal,
          timeZone: 'America/Los_Angeles',
        },
        end: {
          dateTime: endLocal,
          timeZone: 'America/Los_Angeles',
        },
      };

      const response = await this.calendar.events.insert({
        calendarId,
        requestBody: calendarEvent,
      });

      const startFormatted = event.startDateTime.toLocaleString('en-US', {
        weekday: 'long',
        month: 'long',
        day: 'numeric',
        hour: 'numeric',
        minute: '2-digit',
        timeZone: 'America/Los_Angeles',
      });

      console.log(`[Calendar] ✓ Event created: ${startFormatted}`);
      console.log(`[Calendar] Event ID: ${response.data.id}`);
      return true;
    } catch (error) {
      console.error('[Calendar] Failed to add event:', error);
      return false;
    }
  }

  /**
   * Create a court booking event
   */
  async addCourtBooking(sport, date, time, buddy = 'Samuel Wang') {
    // Parse the time string (e.g., "2:00 PM" or "14:00")
    const startDateTime = this.parseTimeToDate(date, time);
    if (!startDateTime) {
      console.error('[Calendar] Failed to parse time:', time);
      return false;
    }

    // Tennis is 90 minutes, pickleball is 60 minutes
    const durationMinutes = sport === 'tennis' ? 90 : 60;
    const endDateTime = new Date(startDateTime.getTime() + durationMinutes * 60 * 1000);

    const sportEmoji = sport === 'tennis' ? '🎾' : '🥒';
    const sportName = sport.charAt(0).toUpperCase() + sport.slice(1);

    return this.addEvent({
      summary: `${sportEmoji} ${sportName} - Bay Club Gateway`,
      location: 'Bay Club Gateway, San Francisco, CA',
      description: `${sportName} court booking at Bay Club Gateway

Buddy: ${buddy}
Duration: ${durationMinutes} minutes

Booked via OpenClaw Bay Club Bot`,
      startDateTime,
      endDateTime,
    });
  }

  /**
   * Parse a time string and combine with a date
   */
  parseTimeToDate(date, timeStr) {
    try {
      // Handle time ranges like "7:00 AM - 8:30 AM" by extracting just the start time
      const timeRangeMatch = timeStr.match(/^(.+?)\s*-\s*.+$/);
      const timeToUse = timeRangeMatch ? timeRangeMatch[1].trim() : timeStr;
      
      // Handle formats like "2:00 PM", "2:00PM", "14:00", "2pm"
      const normalizedTime = timeToUse.trim().toUpperCase();
      
      let hours;
      let minutes = 0;

      // Match patterns like "2:00 PM", "2:30PM", "2 PM", "2PM"
      const match12Hour = normalizedTime.match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)$/);
      // Match patterns like "14:00", "9:30"
      const match24Hour = normalizedTime.match(/^(\d{1,2}):(\d{2})$/);

      if (match12Hour) {
        hours = parseInt(match12Hour[1], 10);
        minutes = match12Hour[2] ? parseInt(match12Hour[2], 10) : 0;
        const isPM = match12Hour[3] === 'PM';
        
        if (hours === 12) {
          hours = isPM ? 12 : 0;
        } else if (isPM) {
          hours += 12;
        }
      } else if (match24Hour) {
        hours = parseInt(match24Hour[1], 10);
        minutes = parseInt(match24Hour[2], 10);
      } else {
        return null;
      }

      const result = new Date(date);
      result.setHours(hours, minutes, 0, 0);
      return result;
    } catch {
      return null;
    }
  }
}

// Singleton instance
const calendarService = new GoogleCalendarService();

module.exports = { GoogleCalendarService, calendarService };


path: README.md
content: # Bay Club Court Manager OpenClaw Bot

Never miss a court slot again. This bot handles tennis and pickleball bookings at Bay Club Connect automatically - you can even text it from WhatsApp.

## What It Does

- 🎾 Check and book tennis courts
- 🏓 Check and book pickleball courts
- 🤖 Runs browser automation via Stagehand
- 📅 Works with "today", "tomorrow", or specific weekdays
- 💬 WhatsApp interface - just text to book
- 📆 Auto-adds bookings to Google Calendar

## The Story

I started building this by SSHing into my DigitalOcean droplet in VSCode, writing the initial automation code. But then I realized I could just... chat with OpenClaw via WhatsApp to finish it.

So I did. The rest of the development - debugging XPath selectors, adding calendar integration, cleaning up files, writing docs, pushing to GitHub - all happened through WhatsApp messages. No more switching between terminal windows.

## Setup

### Option 1: DigitalOcean (Easiest)

1. Go to the [OpenClaw marketplace page](https://marketplace.digitalocean.com/apps/openclaw)
2. Click "Create OpenClaw Droplet"
3. Pick the $21/month plan
4. Wait ~2 minutes for it to boot

Full guide: [How to Run OpenClaw on DigitalOcean](https://www.digitalocean.com/community/tutorials/how-to-run-openclaw)

### Option 2: Local Install

If you already have OpenClaw running locally, skip to "Install the Bot" below.

## Connect WhatsApp

After your droplet starts:

1. SSH in: `ssh root@your-droplet-ip`
2. OpenClaw will show a QR code in the terminal
3. Open WhatsApp → Settings → Linked Devices → Link a Device
4. Scan it
5. Done!

You can now text your droplet like it's a person. Wild.

## Install the Bot

```bash
cd ~/.openclaw/workspace/skills
git clone https://github.com/elizabethsiegle/bayclub-pb-tennis-openclaw-bot.git bayclub_manager
cd bayclub_manager
npm install
```

### Add Your Bay Club Credentials

Either add them to OpenClaw's config:

```bash
openclaw gateway config.patch
```

```json
{
  "env": {
    "BAYCLUB_USERNAME": "your-username",
    "BAYCLUB_PASSWORD": "your-password"
  }
}
```

Or just export them:

```bash
export BAYCLUB_USERNAME="your-username"
export BAYCLUB_PASSWORD="your-password"
```

### Google Calendar (Optional)

If you want bookings to auto-appear in your calendar:

**1. Create a service account**

- Go to [Google Cloud Console](https://console.cloud.google.com/)
- Make a project (or use an existing one)
- Enable the Calendar API
- Create a service account under Credentials
- Generate a JSON key and download it

**2. Share your calendar**

- Open [Google Calendar](https://calendar.google.com)
- Find your calendar (or make one called "Court Bookings")
- Settings → Share with specific people
- Add the service account email (it's in the JSON: `client_email`)
- Give it "Make changes to events" permission

**3. Add credentials**

Copy the JSON to the bot directory:

```bash
cp ~/Downloads/your-key.json ~/.openclaw/workspace/skills/bayclub_manager/google-calendar-credentials.json
```

Set your calendar ID:

```bash
echo 'export GOOGLE_CALENDAR_ID="[REDACTED_SECRET]"' >> ~/.bashrc
source ~/.bashrc
```

That's it. Future bookings will show up in your calendar automatically.

## Usage

### Via WhatsApp (Natural Language)

Just text your OpenClaw agent:

- "Check tennis courts for Sunday"
- "Book pickleball Saturday at 10am"
- "What's available tomorrow?"

### Via Command Line

```bash
# Check what's open
NODE_ENV=development STAGEHAND_ENV=LOCAL HEADLESS=true npx ts-node cli.ts check tennis saturday

# Book a slot
NODE_ENV=development STAGEHAND_ENV=LOCAL HEADLESS=true npx ts-node cli.ts book pickleball sunday "10:00 AM - 11:00 AM"
```

## How It Works

1. Logs into Bay Club Connect
2. Navigates to the booking page using Stagehand
3. Selects sport + duration
4. Finds available time slots
5. Books the court
6. Adds it to your Google Calendar

Pretty straightforward automation. The interesting part is that you can control it entirely through text messages.

## Files

- `cli.ts` - command-line interface
- `bayclub_skills.ts` - OpenClaw skill exports
- `BayClubBot.ts` - browser automation logic
- `GoogleCalendarService.js` - calendar integration
- `SKILL.md` - skill definition
- `package.json` - dependencies

## Why OpenClaw?

Normal workflow: Write code → SSH into server → Test → Debug → Deploy

OpenClaw workflow: Text "build this" → Done

It's legitimately different. You're having a conversation with something that can read files, write code, run tests, and push to GitHub. I built this entire bot without opening a terminal once.

Links:
- [Docs](https://docs.openclaw.ai)
- [GitHub](https://github.com/openclaw/openclaw)
- [DigitalOcean Marketplace](https://marketplace.digitalocean.com/apps/openclaw)
- [Discord Community](https://discord.gg/openclaw)

## Troubleshooting

**"Invalid username or password"**  
Check your Bay Club credentials in the environment variables.

**"Timeout waiting for element"**  
Bay Club might've changed their website. The XPath selectors in `BayClubBot.ts` may need updating.

**Browser won't launch**  
Install Playwright's browser locally:
```bash
npx playwright install chromium
```

In production, Stagehand uses Browserbase's cloud browsers automatically.

## Todo

- Auto-book weekly recurring slots
- Notify when new slots open
- Support other Bay Club locations
- Maybe add SMS notifications


path: bayclub_skills.ts
content: import { BayClubBot, Sport } from './BayClubBot';
const { calendarService } = require('./GoogleCalendarService.js');

/**
 * Helper function to instantiate and log in the bot.
 * Pulls credentials from the OpenClaw environment.
 */
async function getAuthenticatedBot() {
  const bot = new BayClubBot(
    process.env.BAYCLUB_USERNAME!,
    process.env.BAYCLUB_PASSWORD!
  );
  await bot.init();
  await bot.login();
  return bot;
}

/**
 * Helper function to parse day string to Date
 */
function parseDayToDate(day: string): Date {
  const today = new Date();
  const normalized = day.toLowerCase();
  
  if (normalized === 'today') {
    return today;
  }
  
  if (normalized === 'tomorrow') {
    const tomorrow = new Date(today);
    tomorrow.setDate(tomorrow.getDate() + 1);
    return tomorrow;
  }
  
  // Handle day names (monday, tuesday, etc.)
  const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
  const targetDayIndex = dayNames.indexOf(normalized);
  
  if (targetDayIndex !== -1) {
    const currentDayIndex = today.getDay();
    let daysToAdd = targetDayIndex - currentDayIndex;
    
    if (daysToAdd <= 0) {
      daysToAdd += 7; // Next week
    }
    
    const targetDate = new Date(today);
    targetDate.setDate(today.getDate() + daysToAdd);
    return targetDate;
  }
  
  // Default to today if we can't parse
  return today;
}

/**
 * Skill: Get Availability
 * This function is called by OpenClaw when you ask about open slots.
 */
export async function bayclub_get_availability(params: { sport: Sport; day: string }) {
  console.log(`[OpenClaw] Checking availability for ${params.sport} on ${params.day}`);
  const bot = await getAuthenticatedBot();
  
  try {
    await bot.navigateToBooking(params.sport, params.day);
    const times = await bot.getAvailableTimes(params.sport);
    
    if (times.length === 0) {
      return {
        status: "success",
        message: `No available slots found for ${params.sport} on ${params.day}.`
      };
    }

    return {
      status: "success",
      sport: params.sport,
      day: params.day,
      available_slots: times.join(', ')
    };
  } catch (error: any) {
    console.error("[OpenClaw] Availability Error:", error);
    return { status: "error", message: error instanceof Error ? error.message : String(error) };
  } finally {
    await bot.close();
  }
}

/**
 * Skill: Book Court
 * This function is called by OpenClaw to finalize a reservation.
 */
export async function bayclub_book_court(params: { sport: Sport; day: string; time: string }) {
  console.log(`[OpenClaw] Attempting to book ${params.sport} for ${params.day} at ${params.time}`);
  const bot = await getAuthenticatedBot();
  
  try {
    await bot.navigateToBooking(params.sport, params.day);
    const success = await bot.bookCourt(params.time);
    
    if (success) {
      // Add to Google Calendar if available
      try {
        await calendarService.init();
        if (calendarService.isAvailable()) {
          const bookingDate = parseDayToDate(params.day);
          const calendarAdded = await calendarService.addCourtBooking(
            params.sport,
            bookingDate,
            params.time
          );
          
          if (calendarAdded) {
            console.log('[OpenClaw] ✓ Added to Google Calendar');
          }
        }
      } catch (calError) {
        console.error('[OpenClaw] Calendar integration failed (booking still successful):', calError);
      }
      
      return {
        status: "success",
        message: `Successfully booked your ${params.sport} court for ${params.day} at ${params.time}.`
      };
    } else {
      return {
        status: "failure",
        message: `Could not book the ${params.time} slot. It may have been taken or is no longer available.`
      };
    }
  } catch (error: any) {
    console.error("[OpenClaw] Booking Error:", error);
    return { status: "error", message: error instanceof Error ? error.message : String(error) };
  } finally {
    await bot.close();
  }
}


path: cli.ts
content: #!/usr/bin/env npx ts-node
import { bayclub_get_availability, bayclub_book_court } from './bayclub_skills';
import { Sport } from './BayClubBot';

const args = process.argv.slice(2);
const command = args[0];

async function main() {
  console.log('Starting CLI with command:', command);
  console.log('Environment check:');
  console.log('- BAYCLUB_USERNAME:', process.env.BAYCLUB_USERNAME ? '✓ set' : '✗ missing');
  console.log('- BAYCLUB_PASSWORD:', process.env.BAYCLUB_PASSWORD ? '✓ set' : '✗ missing');
  console.log('- NODE_ENV:', process.env.NODE_ENV || 'not set (will use LOCAL mode)');
  
  if (command === 'check' || command === 'availability') {
    const sport = (args[1] || 'tennis') as Sport;
    const day = args[2] || 'today';
    
    console.log(`\nChecking ${sport} availability for ${day}...`);
    const result = await bayclub_get_availability({ sport, day });
    console.log('\nResult:', JSON.stringify(result, null, 2));
  } else if (command === 'book') {
    const sport = (args[1] || 'tennis') as Sport;
    const day = args[2] || 'today';
    const time = args[3];
    
    if (!time) {
      console.error('Usage: cli.ts book <sport> <day> <time>');
      process.exit(1);
    }
    
    console.log(`\nBooking ${sport} for ${day} at ${time}...`);
    const result = await bayclub_book_court({ sport, day, time });
    console.log('\nResult:', JSON.stringify(result, null, 2));
  } else {
    console.log('Usage:');
    console.log('  cli.ts check [sport] [day]     - Check availability');
    console.log('  cli.ts book <sport> <day> <time> - Book a court');
    console.log('\nExamples:');
    console.log('  cli.ts check tennis today');
    console.log('  cli.ts book pickleball tomorrow "10:00 AM"');
  }
}

main().catch(err => {
  console.error('\n❌ Error:', err);
  process.exit(1);
});


path: package.json
content: {
  "name": "bayclub-booking",
  "version": "1.0.0",
  "type": "commonjs",
  "openclaw": {
    "type": "skill"
  },
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs"
    }
  },
  "dependencies": {
    "@browserbasehq/stagehand": "^2.2.0",
    "@types/node": "^25.2.3",
    "googleapis": "^144.0.0",
    "google-auth-library": "^9.15.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.9.3"
  }
}


path: tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "esModuleInterop": true,
    "skipLibCheck": true,
    "strict": false,
    "resolveJsonModule": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["*.ts"],
  "exclude": ["node_modules"]
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)