Skill: shahedkhan30/resy-hunter (version 2.1.0)

--- name: resy-hunter description: "Monitor hard-to-get restaurant reservations on Resy, OpenTable, and Tock. Check availability, manage a watchlist, and get Telegram alerts when tables open up." version: 2.1.0 metadata: openclaw: requires: env: - RESY_API_KEY - RESY_EMAIL - RESY_PASSWORD bins: - curl - jq - node - sed primaryEnv: RESY_API_KEY emoji: "🍽️" --- # Resy Hunter Monitor restaurant reservations across Resy, OpenTable, and Tock. Detect open tables and alert the user via Telegram. ## When to Use - User asks to check if a restaurant has availability - User wants to find open tables at a specific restaurant on a date - User wants to monitor a restaurant for cancellations or new openings - User wants to manage their restaurant watchlist (add, remove, list) - User asks to set up background monitoring or alerts ## Platforms Supported | Platform | Script | Identifier | Auth Required | Notes | |----------|--------|------------|---------------|-------| | Resy | `resy-check.sh` | `venue_id` (integer) | Yes — `RESY_API_KEY` + `RESY_EMAIL` + `RESY_PASSWORD` | Auto-login via API, token cached 12h | | OpenTable | `opentable-check.js` | `restaurant_id` (integer) | Yes — manual login via `opentable-login.js` | One-time visible browser login, session persisted | | Tock | `tock-check.js` | `slug` (URL slug) | No | Playwright bypasses Cloudflare | ## Parsing Natural Language Requests Users speak casually. Extract these fields from every request: | Field | How to parse | Default | |-------|-------------|---------| | **Restaurant** | Name mentioned, or "my list" / "my watchlist" → sweep all watchlist entries | — | | **Party size** | "for 2", "party of 4", "2 people", "two" | Ask if missing | | **Date(s)** | "March 9" → `2026-03-09`. "this Saturday" → resolve to YYYY-MM-DD. "next 30 days" → generate array of dates. "this weekend" → upcoming Sat + Sun. "any Friday in April" → all Fridays in April. | Ask if missing | | **Time window** | "6-9pm" → `earliest: "18:00", latest: "21:00"`. "7:30p" → `earliest: "19:00", latest: "20:00"` (±30min). "dinner" / "prime time" → `earliest: "18:00", latest: "21:00"`. "lunch" → `earliest: "11:30", latest: "14:00"`. "tonight" → `earliest: "18:00", latest: "22:00"` | All times | | **Fallback** | "if nothing, check my list" → on zero results, sweep watchlist with same date/party/time constraints | None | | **Action** | "book me" / "find me a res" / "get me a table" → find availability and present booking links (skill is read-only, never books). "monitor" / "alert me" / "watch for" → set up cron. "add to my list" → watchlist add. | Check availability | **"Book" always means "find availability."** This skill never books. Always present results with a direct booking link so the user can book themselves. ### Example Requests → Execution Plans --- **"Book me a res at Tatiana by Kwame sometime in the next 30 days between 6-9pm for 2 people"** Parsed: - Restaurant: Tatiana by Kwame → run `resy-search-venue.sh "Tatiana by Kwame"` to get venue_id - Dates: next 30 days → generate `["2026-03-06", "2026-03-07", ..., "2026-04-04"]` - Time: 6-9pm → `earliest: "18:00", latest: "21:00"` - Party size: 2 Execute: 1. `bash scripts/resy-search-venue.sh "Tatiana by Kwame"` → get venue_id 2. Loop through each date: `bash scripts/resy-check.sh <venue_id> <date> 2` 3. Filter returned slots to only those between 18:00-21:00 4. Present all dates that have matching slots, with booking links 5. If zero results across all 30 days, offer to add to watchlist for monitoring --- **"Find me a res from my list of restaurants for 3 people this Sat between 6-7:30p, alert me with which ones are available and I will pick one"** Parsed: - Restaurant: "from my list" → sweep entire watchlist - Date: "this Sat" → resolve to upcoming Saturday YYYY-MM-DD - Time: 6-7:30p → `earliest: "18:00", latest: "19:30"` - Party size: 3 (override each entry's party_size for this check) Execute: 1. `bash scripts/watchlist.sh list` → get all entries 2. For each entry, run the platform check script with the resolved Saturday date and party_size=3 3. Filter slots to 18:00-19:30 window 4. Present results grouped by restaurant, with booking links 5. Let the user pick — do not choose for them --- **"Book me at Carbone for 2 on March 9 5-9pm, if no availability find something else from my list"** Parsed: - Restaurant: Carbone → search venue_id - Date: March 9 → `2026-03-09` - Time: 5-9pm → `earliest: "17:00", latest: "21:00"` - Party size: 2 - Fallback: sweep watchlist Execute: 1. `bash scripts/resy-search-venue.sh "Carbone"` → get venue_id 2. `bash scripts/resy-check.sh <venue_id> 2026-03-09 2` 3. Filter slots to 17:00-21:00 4. If slots found → present with booking link, done 5. If no slots → `bash scripts/watchlist.sh list`, then check each entry for 2026-03-09 party_size=2 with 17:00-21:00 filter 6. Present any alternatives found from the watchlist --- **"Any openings at Don Angie tonight for 4?"** Parsed: - Restaurant: Don Angie → search venue_id - Date: tonight → today's date - Time: (tonight implies dinner) → `earliest: "18:00", latest: "22:00"` - Party size: 4 Execute: 1. `bash scripts/resy-search-venue.sh "Don Angie"` → get venue_id 2. `bash scripts/resy-check.sh <venue_id> <today> 4` 3. Filter to 18:00-22:00, present results --- **"Set up alerts for Atomix, party of 2, any Friday or Saturday in April between 7-9"** Parsed: - Restaurant: Atomix → search venue_id - Dates: every Fri + Sat in April 2026 → `["2026-04-03", "2026-04-04", "2026-04-10", "2026-04-11", ...]` - Time: 7-9 → `earliest: "19:00", latest: "21:00"` - Party size: 2 - Action: set up monitoring Execute: 1. `bash scripts/resy-search-venue.sh "Atomix"` → get venue_id 2. `bash scripts/watchlist.sh add '{"name":"Atomix","platform":"resy","venue_id":<id>,"party_size":2,"dates":["2026-04-03","2026-04-04",...],"preferred_times":{"earliest":"19:00","latest":"21:00"}}'` 3. Set up cron if not already running: ```bash openclaw cron add --name "resy-hunter-sweep" --every "15m" --session isolated --message "Run resy-hunter monitor..." --announce ``` 4. Confirm to user: "Monitoring Atomix for Friday/Saturday openings in April, 7-9 PM, party of 2. I'll alert you on Telegram when something opens." --- **"What's available this weekend from my list?"** Parsed: - Restaurant: "from my list" → sweep watchlist - Date: "this weekend" → upcoming Saturday + Sunday - Time: not specified → all times - Party size: not specified → use each entry's configured party_size Execute: 1. `bash scripts/watchlist.sh list` → get all entries 2. For each entry, check both Saturday and Sunday dates using the entry's own party_size 3. Present all results grouped by restaurant and date --- **"Check everything on my list for tomorrow night"** Parsed: - Restaurant: "everything on my list" → sweep watchlist - Date: tomorrow → resolve to YYYY-MM-DD - Time: "night" → `earliest: "18:00", latest: "22:00"` - Party size: use each entry's configured party_size Execute: same as watchlist sweep pattern above --- **"Monitor Torrisi for cancellations, party of 4, March 15, prime time"** Parsed: - Restaurant: Torrisi → search venue_id - Date: March 15 → `2026-03-15` - Time: "prime time" → `earliest: "18:00", latest: "21:00"` - Party size: 4 - Action: "monitor for cancellations" → add to watchlist + set up cron Execute: 1. Search venue_id, add to watchlist, set up cron 2. Confirm monitoring is active --- **"Add Lilia to my watchlist, 2 people, next three Saturdays, dinner time"** Parsed: - Restaurant: Lilia → search venue_id - Dates: next 3 Saturdays → resolve to specific YYYY-MM-DD values - Time: "dinner time" → `earliest: "18:00", latest: "21:00"` - Party size: 2 - Action: watchlist add only (no cron unless asked) Execute: 1. `bash scripts/resy-search-venue.sh "Lilia"` → get venue_id 2. `bash scripts/watchlist.sh add '{"name":"Lilia","platform":"resy","venue_id":<id>,"party_size":2,"dates":["<sat1>","<sat2>","<sat3>"],"preferred_times":{"earliest":"18:00","latest":"21:00"}}'` --- **"Is there anything at 4 Charles or Via Carota for 2 this Thursday?"** Parsed: - Restaurants: two separate — "4 Charles" and "Via Carota" - Date: this Thursday → resolve - Party size: 2 Execute: 1. Search venue_ids for both restaurants 2. Check both for the resolved Thursday date with party_size=2 3. Present results side by side --- **"Cancel monitoring for Carbone"** Parsed: - Action: remove from watchlist Execute: 1. `bash scripts/watchlist.sh list` → find Carbone's id 2. `bash scripts/watchlist.sh remove <id>` 3. If watchlist is now empty, suggest removing the cron job too ## How to Run Checks 1. Determine the platform (ask the user if unclear) 2. Get the restaurant identifier: - **Resy**: If the user doesn't know the `venue_id`, run `scripts/resy-search-venue.sh "<name>" [lat] [long]` to find it - **OpenTable**: The `restaurant_id` is in the OpenTable URL (e.g., `opentable.com/r/restaurant-name-city?rid=123456` → rid is `123456`) - **Tock**: The slug is in the URL (e.g., `exploretock.com/alinea` → slug is `alinea`) 3. Run the appropriate check script: - Resy: `bash ~/.openclaw/skills/resy-hunter/scripts/resy-check.sh <venue_id> <date> <party_size>` - OpenTable: `node ~/.openclaw/skills/resy-hunter/scripts/opentable-check.js <restaurant_id> <date> <party_size>` - Tock: `node ~/.openclaw/skills/resy-hunter/scripts/tock-check.js <slug> <date> <party_size>` 4. Parse the JSON output and present available slots in a clean table format: ``` Restaurant | Date | Time | Type | Platform ``` 5. If no slots found, offer to add to the watchlist for background monitoring ## Watchlist Management The watchlist lives at `~/.openclaw/data/resy-hunter/watchlist.json` (separate from the skill directory so reinstalls don't wipe user data). - **List**: `bash ~/.openclaw/skills/resy-hunter/scripts/watchlist.sh list` - **Add**: `bash ~/.openclaw/skills/resy-hunter/scripts/watchlist.sh add '<json>'` - JSON must include: `name`, `platform` (resy/opentable/tock), platform identifier (`venue_id`/`restaurant_id`/`slug`), `party_size`, `dates` array - Optional: `preferred_times` object with `earliest` and `latest` in HH:MM format - **Remove**: `bash ~/.openclaw/skills/resy-hunter/scripts/watchlist.sh remove <id>` - **Set Priority**: `bash ~/.openclaw/skills/resy-hunter/scripts/watchlist.sh set-priority <id> <high|low>` ### Priority Levels - **high** — Telegram alerts fire immediately when new slots appear - **low** — Slots are tracked (dedup works) but Telegram alerts only when the user explicitly asks for a sweep Default priority is `low` when adding entries. Set it to `high` for restaurants the user actively wants alerts on. The JSON report from `monitor.sh` always includes all priorities. ### Default Time Window All entries default to a **6:00 PM – 10:00 PM** time window. Slots outside this window are filtered out. Override per restaurant via `preferred_times` when adding: Example add: ```bash bash ~/.openclaw/skills/resy-hunter/scripts/watchlist.sh add '{ "name": "Carbone", "platform": "resy", "venue_id": 5286, "party_size": 2, "dates": ["2026-03-15", "2026-03-22"], "preferred_times": {"earliest": "18:00", "latest": "21:30"}, "priority": "high" }' ``` ## Background Monitoring The monitor runs checks in parallel and staggers platforms by frequency: - **Resy** (curl): checked every sweep, up to 8 concurrent - **Tock** (Playwright): checked once per hour, up to 2 concurrent - **OpenTable** (Playwright): checked once per hour, up to 2 concurrent To set up cron monitoring, run: ```bash openclaw cron add \ --name "resy-hunter-sweep" \ --every "15m" \ --session isolated \ --message "Run resy-hunter monitor. Execute bash ~/.openclaw/skills/resy-hunter/scripts/monitor.sh. If new availability is found in the output JSON (new_availability array is non-empty), format a clear alert listing each restaurant name, platform, date, available time slots, and party size. Include a direct booking link. If nothing new, respond only: No new availability." \ --announce ``` The 15-minute cron means Resy is checked every 15 minutes while Tock and OpenTable are automatically checked once per hour (the monitor tracks timestamps and skips platforms checked less than 60 minutes ago). For high-frequency sniping on a specific date (e.g., when reservations drop): ```bash openclaw cron add \ --name "resy-hunter-snipe" \ --every "2m" \ --session isolated \ --message "Snipe mode: run bash ~/.openclaw/skills/resy-hunter/scripts/resy-check.sh <venue_id> <date> <party_size>. Report immediately if any slot appears." \ --announce ``` To stop monitoring: `openclaw cron remove --name "resy-hunter-sweep"` ## Manual Monitor Sweep To run a one-time sweep of the entire watchlist: ```bash bash ~/.openclaw/skills/resy-hunter/scripts/monitor.sh ``` To check only a specific platform (bypasses the hourly interval timer): ```bash bash ~/.openclaw/skills/resy-hunter/scripts/monitor.sh --platform resy bash ~/.openclaw/skills/resy-hunter/scripts/monitor.sh --platform tock bash ~/.openclaw/skills/resy-hunter/scripts/monitor.sh --platform opentable ``` Multiple platforms can be combined: ```bash bash ~/.openclaw/skills/resy-hunter/scripts/monitor.sh --platform resy --platform tock ``` Interpret the output JSON: - `new_availability` array: newly detected slots since last sweep - `total_checked`: number of restaurant+date combinations checked - `total_with_availability`: how many had open slots ## Sending Notifications Manually ```bash bash ~/.openclaw/skills/resy-hunter/scripts/notify.sh "Your message here" ``` This sends a Telegram message. The bot token is pulled from OpenClaw's Telegram channel config (`channels.telegram.botToken`). Falls back to `TELEGRAM_BOT_TOKEN` env var if config not found. ## Booking Links When reporting availability, always include a direct booking link: - **Resy**: `https://resy.com/cities/<city>-<region>/venues/<slug>?date=<YYYY-MM-DD>&seats=<party_size>` - **OpenTable**: `https://www.opentable.com/booking/widget?rid=<restaurant_id>&datetime=<ISO>&covers=<party_size>` - **Tock**: `https://www.exploretock.com/<slug>/search?date=<YYYY-MM-DD>&size=<party_size>` ## Error Handling - If a Resy request returns HTTP 419, the script automatically clears the cached token and re-authenticates. No manual action needed. - If Resy login fails (HTTP 401/403), check `RESY_EMAIL` and `RESY_PASSWORD`. - If OpenTable returns `session_expired: true`, run `node ~/.openclaw/skills/resy-hunter/scripts/opentable-login.js` to re-authenticate manually. The monitor will send a Telegram alert when this happens. - If Tock returns `blocked: true` in output, Cloudflare blocked even the Playwright browser. Return the URL for the user to check manually. This is rare — retrying usually works. ## Credential Setup **Resy (auto-login via API):** 1. `RESY_API_KEY` — one-time extraction from browser DevTools (static, never expires): - Log into resy.com → DevTools → Network tab - Find any request to `api.resy.com` - `Authorization` header → value after `ResyAPI api_key=` is the API key 2. `RESY_EMAIL` — your Resy account email 3. `RESY_PASSWORD` — your Resy account password 4. Auth tokens are fetched and cached automatically (12-hour TTL). No manual token extraction needed. **OpenTable (manual login via Playwright):** 1. Run once: `node ~/.openclaw/skills/resy-hunter/scripts/opentable-login.js` 2. A visible browser opens to OpenTable's login page 3. Log in with your email + OTP code manually 4. The script detects login and saves session cookies to `~/.openclaw/data/resy-hunter/opentable-session.json` 5. Future runs use the saved session headlessly. If it expires, the monitor sends a Telegram alert telling you to re-run `opentable-login.js`. **Tock (no credentials):** No credentials needed. Playwright uses a real browser that passes Cloudflare Turnstile challenges automatically. ## First-Time Setup After installing the skill, run once to install dependencies: ```bash cd ~/.openclaw/skills/resy-hunter npm install npx playwright install chromium ``` ## Important - This skill is **read-only**. Never attempt to book a reservation. The user books manually. - Respect rate limits. Do not run checks more frequently than every 2 minutes. - Dates in the past should be skipped automatically by the monitor script.

path: examples/watchlist.example.json
content: {
  "restaurants": [
    {
      "id": 1,
      "name": "Carbone",
      "platform": "resy",
      "venue_id": 5286,
      "party_size": 2,
      "dates": ["2026-03-15", "2026-03-22", "2026-03-29"],
      "preferred_times": {
        "earliest": "18:00",
        "latest": "21:30"
      },
      "added_at": "2026-03-05T20:00:00Z",
      "active": true
    },
    {
      "id": 2,
      "name": "The French Laundry",
      "platform": "tock",
      "slug": "the-french-laundry",
      "party_size": 4,
      "dates": ["2026-04-01", "2026-04-08"],
      "preferred_times": {
        "earliest": "17:00",
        "latest": "20:00"
      },
      "added_at": "2026-03-05T20:05:00Z",
      "active": true
    },
    {
      "id": 3,
      "name": "Eleven Madison Park",
      "platform": "opentable",
      "restaurant_id": 1505,
      "party_size": 2,
      "dates": ["2026-03-20", "2026-03-27"],
      "preferred_times": {
        "earliest": "19:00",
        "latest": "21:00"
      },
      "added_at": "2026-03-05T20:10:00Z",
      "active": true
    }
  ]
}


path: package-lock.json
content: {
  "name": "resy-hunter",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "resy-hunter",
      "dependencies": {
        "playwright": "^1.50.0"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.2",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/playwright": {
      "version": "1.58.2",
      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "dependencies": {
        "playwright-core": "1.58.2"
      },
      "bin": {
        "playwright": "cli.js"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "fsevents": "2.3.2"
      }
    },
    "node_modules/playwright-core": {
      "version": "1.58.2",
      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "bin": {
        "playwright-core": "cli.js"
      },
      "engines": {
        "node": ">=18"
      }
    }
  }
}


path: package.json
content: {
  "private": true,
  "name": "resy-hunter",
  "description": "Restaurant reservation hunter - Playwright browser automation for OpenTable and Tock",
  "dependencies": {
    "playwright": "^1.50.0"
  }
}


path: references/platforms.md
content: # OpenTable & Tock Reference

## OpenTable (Playwright)

OpenTable has no public consumer API and no login endpoint for programmatic auth. This skill uses Playwright to run a real Chromium browser that logs in with email/password and captures availability data.

### How It Works

1. Launches headless Chromium with persistent session state (`.playwright-state/opentable.json`)
2. Navigates to the OpenTable restaurant page with date/party params
3. Intercepts network responses containing availability data (XHR/fetch to availability endpoints)
4. If no active session, detects the login wall and fills OPENTABLE_EMAIL + OPENTABLE_PASSWORD
5. Saves session cookies/localStorage for reuse across runs
6. Parses availability JSON from intercepted responses, with DOM fallback
7. Outputs JSON to stdout

### Script Interface

```
node scripts/opentable-check.js <restaurant_id> <date> <party_size> [time]
```

### Environment Variables

| Variable | Description |
|----------|-------------|
| `OPENTABLE_EMAIL` | OpenTable account email |
| `OPENTABLE_PASSWORD` | OpenTable account password |

### Session Persistence

Session state is saved to `~/.openclaw/skills/resy-hunter/.playwright-state/opentable.json`. Delete this file to force a fresh login.

### Finding the Restaurant ID

The `rid` can be found in OpenTable URLs:
- `https://www.opentable.com/r/restaurant-name-city?rid=123456`
- The `rid` query parameter is the restaurant ID

### Booking URL

```
https://www.opentable.com/booking/widget?rid=<rid>&datetime=<ISO>&covers=<party_size>
```

---

## Tock (Playwright)

Tock (exploretock.com) has no API. The site is protected by Cloudflare Turnstile which blocks curl/wget. This skill uses Playwright to run a real Chromium browser that passes Cloudflare challenges.

### How It Works

1. Launches headless Chromium (real browser fingerprint passes Turnstile)
2. Navigates to the Tock search URL with date/party/time params
3. If Cloudflare challenge appears, waits up to 18 seconds for auto-resolution
4. Once page renders, tries three extraction strategies:
   - Extract `__NEXT_DATA__` JSON from the DOM (most reliable)
   - Query DOM for time slot elements (fallback)
   - Detect "no availability" text
5. If still blocked after wait, returns URL for manual check (`blocked: true`)
6. Outputs JSON to stdout

### Script Interface

```
node scripts/tock-check.js <slug> <date> <party_size> [time]
```

### No Credentials Required

Tock search pages are public. No login needed.

### Search URL Format

```
https://www.exploretock.com/<slug>/search?date=<YYYY-MM-DD>&size=<party_size>&time=<HH:MM>
```

### Booking URL

Same as the search URL:
```
https://www.exploretock.com/<slug>/search?date=<YYYY-MM-DD>&size=<party_size>
```


path: references/resy-api.md
content: # Resy API Reference (Reverse-Engineered)

Resy has no official public API. All endpoints below are reverse-engineered from network traffic.

## Base URL

`https://api.resy.com`

## Authentication Headers

Every request requires:

```
Authorization: ResyAPI [REDACTED_SECRET]"
x-resy-auth-[REDACTED_SECRET]
Accept: application/json
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
```

### How to Get the API Key

The API key is static and never expires. Extract it once:
1. Log into resy.com in a browser
2. Open Developer Tools → Network tab
3. Find any request to `api.resy.com`
4. `Authorization` header → the part after `ResyAPI api_key=` is the API key

### Token Management

Auth tokens are fetched automatically via `resy-auth.sh` using your email/password. Tokens are cached for 12 hours. On HTTP 419, the script automatically re-authenticates.

## Endpoints

### POST /3/auth/password — Login

Authenticate with email and password to obtain an auth token.

**Content-Type:** `application/x-www-form-urlencoded`

**Headers:**
```
Authorization: ResyAPI [REDACTED_SECRET]"
```

**Body (form-encoded):**
| Param | Type | Description |
|-------|------|-------------|
| `email` | string | Resy account email |
| `password` | string | Resy account password |

**Response:**
```json
{
  "token": "eyJhb...",
  "payment_methods": [
    { "id": 12345 }
  ]
}
```

The `token` value is used as the `x-resy-auth-token` header in subsequent requests.

### POST /4/find — Search Availability

Query available reservation slots for a venue.

**Content-Type:** `application/json`

**Request body:**
| Field | Type | Description |
|-------|------|-------------|
| `venue_id` | int | Restaurant identifier |
| `day` | string | Date in YYYY-MM-DD format |
| `party_size` | int | Number of guests |
| `lat` | float | Latitude (use 0 when venue_id is provided) |
| `long` | float | Longitude (use 0 when venue_id is provided) |

**Example request body:**
```json
{"lat": 0, "long": 0, "day": "2026-03-06", "party_size": 2, "venue_id": 6194}
```

**Response shape:**
```json
{
  "results": {
    "venues": [
      {
        "venue": {
          "id": { "resy": 5286 },
          "name": "Carbone",
          "slug": "carbone",
          "location": { "city": "New York", "neighborhood": "Greenwich Village" }
        },
        "slots": [
          {
            "date": { "start": "2026-03-15 19:30:00", "end": "2026-03-15 21:30:00" },
            "config": { "type": "Dining Room", "token": "..." }
          }
        ]
      }
    ]
  }
}
```

### GET /3/venuesearch/search — Search Venues

Search for restaurants by name.

**Parameters (query string):**
| Param | Type | Description |
|-------|------|-------------|
| `query` | string | Search term |
| `geo.lat` | float | Latitude |
| `geo.long` | float | Longitude |
| `per_page` | int | Results per page |
| `types` | string | Filter type (use `venue`) |

**Response shape:**
```json
{
  "search": {
    "hits": [
      {
        "id": { "resy": 5286 },
        "name": "Carbone",
        "url_slug": "carbone",
        "location": { "city": "New York" }
      }
    ]
  }
}
```

## Booking URLs

Direct link format: `https://resy.com/cities/<city>/<slug>?date=<YYYY-MM-DD>&seats=<party_size>`

Example: `https://resy.com/cities/ny/carbone?date=2026-03-15&seats=2`


path: scripts/monitor.sh
content: #!/usr/bin/env bash
# monitor.sh — Sweep the entire watchlist and report new availability
# Usage: ./monitor.sh [--platform resy|tock|opentable]
#
# Options:
#   --platform <name>   Only check this platform (bypasses interval timer).
#                        Can be specified multiple times. Omit to check all.
#
# Runs checks in parallel:
#   - Resy (curl):       up to 8 concurrent, every sweep
#   - Tock (Playwright): up to 2 concurrent, once per hour
#   - OpenTable (Playwright): up to 2 concurrent, once per hour
# All platform batches run simultaneously.
#
# Defaults:
#   - Time window: 6-10 PM unless overridden per entry
#   - Priority: only HIGH priority entries trigger Telegram alerts
#   - Telegram: max 5 slots per restaurant, sorted by 7-9 PM proximity

set -euo pipefail

# --- Parse arguments ---
FILTER_PLATFORMS=()
while [[ $# -gt 0 ]]; do
  case "$1" in
    --platform)
      if [[ $# -lt 2 ]]; then
        echo '{"error": "Usage: monitor.sh [--platform resy|tock|opentable]"}' >&2
        exit 1
      fi
      p="$2"
      if [[ "$p" != "resy" && "$p" != "tock" && "$p" != "opentable" ]]; then
        echo "{\"error\": \"Unknown platform: ${p}. Use resy, tock, or opentable.\"}" >&2
        exit 1
      fi
      FILTER_PLATFORMS+=("$p")
      shift 2
      ;;
    *)
      echo "{\"error\": \"Unknown option: $1. Usage: monitor.sh [--platform resy|tock|opentable]\"}" >&2
      exit 1
      ;;
  esac
done

# Ensure Node.js is on PATH (for Playwright scripts)
if [[ -d "$HOME/.local/node/bin" ]]; then
  export PATH="$HOME/.local/node/bin:$PATH"
fi

SKILL_DIR="$HOME/.openclaw/skills/resy-hunter"
SCRIPTS_DIR="${SKILL_DIR}/scripts"
DATA_DIR="$HOME/.openclaw/data/resy-hunter"
mkdir -p "$DATA_DIR"

# Migrate old data files from skill dir to data dir
if [[ -f "${SKILL_DIR}/watchlist.json" && ! -f "${DATA_DIR}/watchlist.json" ]]; then
  cp "${SKILL_DIR}/watchlist.json" "${DATA_DIR}/watchlist.json"
fi
if [[ -f "${SKILL_DIR}/.last-seen.json" && ! -f "${DATA_DIR}/.last-seen.json" ]]; then
  cp "${SKILL_DIR}/.last-seen.json" "${DATA_DIR}/.last-seen.json"
fi

WATCHLIST_FILE="${DATA_DIR}/watchlist.json"
STATE_FILE="${DATA_DIR}/.last-seen.json"
PLATFORM_TS_FILE="${DATA_DIR}/.platform-timestamps.json"

# Initialize files if needed
[[ -f "$STATE_FILE" ]] || echo '{"seen":{}}' > "$STATE_FILE"
[[ -f "$WATCHLIST_FILE" ]] || echo '{"restaurants":[]}' > "$WATCHLIST_FILE"
[[ -f "$PLATFORM_TS_FILE" ]] || echo '{}' > "$PLATFORM_TS_FILE"

TODAY=$(date -u +"%Y-%m-%d")
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
NOW_EPOCH=$(date +%s)
START_TIME=$NOW_EPOCH

# --- Platform check intervals (seconds) ---
RESY_INTERVAL=0
TOCK_INTERVAL=3600
OPENTABLE_INTERVAL=3600

platform_is_due() {
  local plat=$1 interval=$2
  if [[ "$interval" -eq 0 ]]; then return 0; fi
  local last_ts
  last_ts=$(jq -r --arg p "$plat" '.[$p] // 0' "$PLATFORM_TS_FILE")
  local elapsed=$(( NOW_EPOCH - last_ts ))
  [[ $elapsed -ge $interval ]]
}

# Read active entries
entries=$(jq -c '[.restaurants[] | select(.active == true)]' "$WATCHLIST_FILE")
entry_count=$(echo "$entries" | jq 'length')

if [[ "$entry_count" == "0" ]]; then
  jq -n --arg ts "$TIMESTAMP" '{
    timestamp: $ts,
    new_availability: [],
    total_checked: 0,
    total_with_availability: 0,
    message: "Watchlist is empty"
  }'
  exit 0
fi

# Determine which platforms are due
if [[ ${#FILTER_PLATFORMS[@]} -gt 0 ]]; then
  # Explicit --platform flag: only those platforms, bypass interval check
  resy_due=false; tock_due=false; opentable_due=false
  for fp in "${FILTER_PLATFORMS[@]}"; do
    case "$fp" in
      resy)      resy_due=true ;;
      tock)      tock_due=true ;;
      opentable) opentable_due=true ;;
    esac
  done
else
  # Default: all platforms, subject to interval check
  resy_due=true; tock_due=true; opentable_due=true
  platform_is_due "resy" "$RESY_INTERVAL" || resy_due=false
  platform_is_due "tock" "$TOCK_INTERVAL" || tock_due=false
  platform_is_due "opentable" "$OPENTABLE_INTERVAL" || opentable_due=false
fi

# Pre-fetch Resy auth token
if [[ "$resy_due" == "true" ]]; then
  has_resy=$(echo "$entries" | jq '[.[] | select(.platform == "resy")] | length')
  if [[ "$has_resy" -gt 0 && -z "${RESY_AUTH_TOKEN:-}" ]]; then
    RESY_AUTH_TOKEN=$(bash "${SCRIPTS_DIR}/resy-auth.sh" 2>/dev/null || true)
    if [[ -n "$RESY_AUTH_TOKEN" ]]; then
      export RESY_AUTH_TOKEN
    fi
  fi
fi

# ===========================================================
#  Phase 1: Build job list
# ===========================================================
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT

resy_count=0; tock_count=0; opentable_count=0
skipped_tock=0; skipped_opentable=0
job_id=0

for i in $(seq 0 $((entry_count - 1))); do
  entry=$(echo "$entries" | jq -c ".[$i]")
  platform=$(echo "$entry" | jq -r '.platform')
  name=$(echo "$entry" | jq -r '.name')

  case "$platform" in
    resy)      [[ "$resy_due" != "true" ]] && continue ;;
    tock)      [[ "$tock_due" != "true" ]] && { skipped_tock=$((skipped_tock + 1)); continue; } ;;
    opentable) [[ "$opentable_due" != "true" ]] && { skipped_opentable=$((skipped_opentable + 1)); continue; } ;;
  esac

  dates=$(echo "$entry" | jq -r '.dates[]')

  for date in $dates; do
    [[ "$date" < "$TODAY" ]] && continue

    job_id=$((job_id + 1))
    echo "$entry"    > "${TMP_DIR}/job-${job_id}.entry"
    echo "$date"     > "${TMP_DIR}/job-${job_id}.date"
    echo "$platform" > "${TMP_DIR}/job-${job_id}.platform"
    echo "$name"     > "${TMP_DIR}/job-${job_id}.name"

    case "$platform" in
      resy)      resy_count=$((resy_count + 1));      echo "$job_id" >> "${TMP_DIR}/batch-resy" ;;
      tock)      tock_count=$((tock_count + 1));       echo "$job_id" >> "${TMP_DIR}/batch-tock" ;;
      opentable) opentable_count=$((opentable_count + 1)); echo "$job_id" >> "${TMP_DIR}/batch-opentable" ;;
    esac
  done
done

total_jobs=$job_id

if [[ "$total_jobs" -eq 0 ]]; then
  skip_msg=""
  [[ $skipped_tock -gt 0 ]] && skip_msg="${skip_msg}tock: skipped (checked <1h ago). "
  [[ $skipped_opentable -gt 0 ]] && skip_msg="${skip_msg}opentable: skipped (checked <1h ago). "
  jq -n --arg ts "$TIMESTAMP" --arg msg "No checks to run. ${skip_msg}" '{
    timestamp: $ts, new_availability: [], total_checked: 0,
    total_with_availability: 0, message: $msg
  }'
  exit 0
fi

# ===========================================================
#  Phase 2: Run checks in parallel
# ===========================================================
run_check() {
  local jid=$1
  local entry; entry=$(cat "${TMP_DIR}/job-${jid}.entry")
  local date;  date=$(cat "${TMP_DIR}/job-${jid}.date")
  local platform; platform=$(cat "${TMP_DIR}/job-${jid}.platform")
  local name; name=$(cat "${TMP_DIR}/job-${jid}.name")
  local party_size; party_size=$(echo "$entry" | jq -r '.party_size')
  local result='{"slots":[]}'

  case "$platform" in
    resy)
      local venue_id; venue_id=$(echo "$entry" | jq -r '.venue_id')
      result=$(bash "${SCRIPTS_DIR}/resy-check.sh" "$venue_id" "$date" "$party_size" 2>/dev/null || echo '{"slots":[]}')
      ;;
    opentable)
      local rid; rid=$(echo "$entry" | jq -r '.restaurant_id')
      result=$(node "${SCRIPTS_DIR}/opentable-check.js" "$rid" "$date" "$party_size" 2>/dev/null || echo '{"slots":[]}')
      ;;
    tock)
      local slug; slug=$(echo "$entry" | jq -r '.slug')
      result=$(node "${SCRIPTS_DIR}/tock-check.js" "$slug" "$date" "$party_size" 2>/dev/null || echo '{"slots":[]}')
      ;;
  esac

  echo "$result" > "${TMP_DIR}/result-${jid}.json"
  # Write name for progress display
  echo "$name" > "${TMP_DIR}/last-checked"
  touch "${TMP_DIR}/done-${platform}-${jid}"
}

run_batch() {
  local max=$1; shift
  local count=0
  for jid in "$@"; do
    run_check "$jid" &
    count=$((count + 1))
    if [[ $((count % max)) -eq 0 ]]; then wait; fi
  done
  wait
}

show_progress() {
  while true; do
    local r_done; r_done=$(ls "${TMP_DIR}"/done-resy-* 2>/dev/null | wc -l | tr -d ' ')
    local t_done; t_done=$(ls "${TMP_DIR}"/done-tock-* 2>/dev/null | wc -l | tr -d ' ')
    local o_done; o_done=$(ls "${TMP_DIR}"/done-opentable-* 2>/dev/null | wc -l | tr -d ' ')
    local done_count=$((r_done + t_done + o_done))
    local pct=0
    [[ $total_jobs -gt 0 ]] && pct=$((done_count * 100 / total_jobs))
    local elapsed=$(( $(date +%s) - START_TIME ))
    local last; last=$(cat "${TMP_DIR}/last-checked" 2>/dev/null || echo "...")

    local parts=""
    [[ $resy_count -gt 0 ]] && parts="${parts}resy ${r_done}/${resy_count}  "
    [[ $tock_count -gt 0 ]] && parts="${parts}tock ${t_done}/${tock_count}  "
    [[ $opentable_count -gt 0 ]] && parts="${parts}ot ${o_done}/${opentable_count}"

    printf '\r  ⏳ [%d/%d %d%%] %s — %s (%ds)   ' "$done_count" "$total_jobs" "$pct" "$parts" "$last" "$elapsed" >&2

    if [[ $done_count -ge $total_jobs ]]; then
      printf '\r  ✅ Done: %d checks in %ds  [%s]                    \n' "$total_jobs" "$elapsed" "$parts" >&2
      break
    fi
    sleep 0.5
  done
}

# Header
printf '  🍽️  Sweeping %d restaurants (%d checks)\n' "$entry_count" "$total_jobs" >&2
status_parts=""
[[ $resy_count -gt 0 ]] && status_parts="${status_parts}resy: ${resy_count}  "
[[ $tock_count -gt 0 ]] && status_parts="${status_parts}tock: ${tock_count}  "
[[ $opentable_count -gt 0 ]] && status_parts="${status_parts}opentable: ${opentable_count}  "
[[ $skipped_tock -gt 0 ]] && status_parts="${status_parts}(tock skipped — checked <1h ago)  "
[[ $skipped_opentable -gt 0 ]] && status_parts="${status_parts}(opentable skipped — checked <1h ago)  "
printf '     %s\n' "$status_parts" >&2

# Start progress monitor
show_progress &
progress_pid=$!

# Launch all platform batches simultaneously
resy_pid=""; tock_pid=""; ot_pid=""
if [[ -f "${TMP_DIR}/batch-resy" ]]; then
  run_batch 8 $(cat "${TMP_DIR}/batch-resy") &
  resy_pid=$!
fi
if [[ -f "${TMP_DIR}/batch-tock" ]]; then
  run_batch 2 $(cat "${TMP_DIR}/batch-tock") &
  tock_pid=$!
fi
if [[ -f "${TMP_DIR}/batch-opentable" ]]; then
  run_batch 2 $(cat "${TMP_DIR}/batch-opentable") &
  ot_pid=$!
fi

[[ -n "$resy_pid" ]] && wait "$resy_pid" 2>/dev/null || true
[[ -n "$tock_pid" ]] && wait "$tock_pid" 2>/dev/null || true
[[ -n "$ot_pid" ]]   && wait "$ot_pid"   2>/dev/null || true
wait "$progress_pid" 2>/dev/null || true

# Update platform timestamps
if [[ "$resy_due" == "true" && $resy_count -gt 0 ]]; then
  jq --argjson t "$NOW_EPOCH" '.resy = $t' "$PLATFORM_TS_FILE" > "${PLATFORM_TS_FILE}.tmp" && mv "${PLATFORM_TS_FILE}.tmp" "$PLATFORM_TS_FILE"
fi
if [[ "$tock_due" == "true" && $tock_count -gt 0 ]]; then
  jq --argjson t "$NOW_EPOCH" '.tock = $t' "$PLATFORM_TS_FILE" > "${PLATFORM_TS_FILE}.tmp" && mv "${PLATFORM_TS_FILE}.tmp" "$PLATFORM_TS_FILE"
fi
if [[ "$opentable_due" == "true" && $opentable_count -gt 0 ]]; then
  jq --argjson t "$NOW_EPOCH" '.opentable = $t' "$PLATFORM_TS_FILE" > "${PLATFORM_TS_FILE}.tmp" && mv "${PLATFORM_TS_FILE}.tmp" "$PLATFORM_TS_FILE"
fi

# ===========================================================
#  Phase 3: Process results (dedup, filter, report, notify)
# ===========================================================
all_results="[]"       # Full report (all priorities)
alert_results="[]"     # Telegram alerts (high priority only)
total_checked=0
total_with_availability=0
ot_session_expired=false

for jid in $(seq 1 "$total_jobs"); do
  result_file="${TMP_DIR}/result-${jid}.json"
  [[ -f "$result_file" ]] || continue

  entry=$(cat "${TMP_DIR}/job-${jid}.entry")
  date=$(cat "${TMP_DIR}/job-${jid}.date")
  platform=$(cat "${TMP_DIR}/job-${jid}.platform")
  name=$(echo "$entry" | jq -r '.name')
  party_size=$(echo "$entry" | jq -r '.party_size')
  priority=$(echo "$entry" | jq -r '.priority // "low"')
  result=$(cat "$result_file")

  total_checked=$((total_checked + 1))

  # Handle OpenTable session expiry
  if [[ "$platform" == "opentable" ]]; then
    ses_exp=$(echo "$result" | jq -r '.session_expired // false')
    if [[ "$ses_exp" == "true" ]]; then
      ot_session_expired=true
      continue
    fi
  fi

  # Default time window: 6-10 PM unless overridden per entry
  earliest=$(echo "$entry" | jq -r '.preferred_times.earliest // "18:00"')
  latest=$(echo "$entry" | jq -r '.preferred_times.latest // "22:00"')

  # Extract slots
  slots=$(echo "$result" | jq -c '.slots // []')
  slot_count=$(echo "$slots" | jq 'length')

  if [[ "$slot_count" -gt 0 ]]; then
    # Filter by time window
    if [[ -n "$earliest" && -n "$latest" ]]; then
      slots=$(echo "$slots" | jq -c --arg e "$earliest" --arg l "$latest" '[
        .[] | select(
          (.time_24h // (.time_start | split(" ") | last | split("T") | last | .[0:5])) as $t |
          $t >= $e and $t <= $l
        )
      ]')
      slot_count=$(echo "$slots" | jq 'length')
    fi
  fi

  if [[ "$slot_count" -gt 0 ]]; then
    total_with_availability=$((total_with_availability + 1))

    # Deduplication
    state_key="${platform}:$(echo "$entry" | jq -r '.venue_id // .restaurant_id // .slug'):${date}"
    prev_slots=$(jq -c --arg key "$state_key" '.seen[$key] // []' "$STATE_FILE")

    new_slots=$(jq -c --argjson prev "$prev_slots" '[
      .[] | . as $slot |
      if ($prev | map(.time_start) | index($slot.time_start)) then empty
      else $slot end
    ]' <<< "$slots")

    new_count=$(echo "$new_slots" | jq 'length')

    if [[ "$new_count" -gt 0 ]]; then
      # Build booking URL
      booking_url=""
      case "$platform" in
        resy)
          slug=$(echo "$result" | jq -r '.venue_slug // ""')
          city=$(echo "$result" | jq -r '.venue_city // "New York"' | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
          region=$(echo "$result" | jq -r '.venue_region // "ny"' | tr '[:upper:]' '[:lower:]')
          booking_url="https://resy.com/cities/${city}-${region}/venues/${slug}?date=${date}&seats=${party_size}"
          ;;
        opentable)
          rid=$(echo "$entry" | jq -r '.restaurant_id')
          booking_url="https://www.opentable.com/booking/widget?rid=${rid}&datetime=${date}T19:00&covers=${party_size}"
          ;;
        tock)
          tslug=$(echo "$entry" | jq -r '.slug')
          booking_url="https://www.exploretock.com/${tslug}/search?date=${date}&size=${party_size}"
          ;;
      esac

      entry_result=$(jq -n \
        --arg name "$name" \
        --arg platform "$platform" \
        --arg date "$date" \
        --argjson party_size "$party_size" \
        --argjson slots "$new_slots" \
        --arg url "$booking_url" \
        --arg priority "$priority" \
        '{
          restaurant: $name,
          platform: $platform,
          date: $date,
          party_size: $party_size,
          priority: $priority,
          slots: $slots,
          booking_url: $url
        }')

      # Always add to full report
      all_results=$(echo "$all_results" | jq --argjson entry "$entry_result" '. += [$entry]')

      # Only add HIGH priority to alert list
      if [[ "$priority" == "high" ]]; then
        alert_results=$(echo "$alert_results" | jq --argjson entry "$entry_result" '. += [$entry]')
      fi
    fi

    # Update state with current slots (all priorities)
    state_update=$(jq --arg key "$state_key" --argjson slots "$slots" --arg ts "$TIMESTAMP" '
      .seen[$key] = [$slots[] | . + {first_seen: $ts}]
    ' "$STATE_FILE")
    echo "$state_update" > "$STATE_FILE"
  fi
done

# Clean up expired entries from state
jq --arg today "$TODAY" '
  .seen = (.seen | to_entries | map(
    select((.key | split(":") | last) >= $today)
  ) | from_entries)
' "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE"

# Output the full report (includes all priorities)
report=$(jq -n \
  --arg ts "$TIMESTAMP" \
  --argjson results "$all_results" \
  --argjson checked "$total_checked" \
  --argjson avail "$total_with_availability" \
  '{
    timestamp: $ts,
    new_availability: $results,
    total_checked: $checked,
    total_with_availability: $avail
  }')

echo "$report"

# --- Telegram notification (HIGH priority only, max 5 slots) ---
alert_count=$(echo "$alert_results" | jq 'length')
if [[ "$alert_count" -gt 0 ]]; then
  msg="🍽️ *New Availability Found!*"$'\n'
  for j in $(seq 0 $((alert_count - 1))); do
    r_name=$(echo "$alert_results" | jq -r --argjson i "$j" '.[$i].restaurant')
    r_platform=$(echo "$alert_results" | jq -r --argjson i "$j" '.[$i].platform')
    r_date=$(echo "$alert_results" | jq -r --argjson i "$j" '.[$i].date')
    r_party=$(echo "$alert_results" | jq -r --argjson i "$j" '.[$i].party_size')
    r_url=$(echo "$alert_results" | jq -r --argjson i "$j" '.[$i].booking_url')

    # Sort slots by proximity to 8 PM (prime time center), cap at 5
    r_times=$(echo "$alert_results" | jq -r --argjson i "$j" '
      .[$i].slots |
      sort_by(
        .time_24h | split(":") |
        ((.[0] | tonumber) * 60 + (.[1] | tonumber)) |
        (. - 1200) | if . < 0 then -. else . end
      ) |
      [limit(5; .[]) |
        .time_start + (
          if (.deposit_fee // 0) > 0
          then " ($" + (.deposit_fee | round | tostring) + " deposit)"
          else ""
          end
        )
      ] | join(", ")')

    r_total=$(echo "$alert_results" | jq -r --argjson i "$j" '.[$i].slots | length')
    r_extra=""
    if [[ "$r_total" -gt 5 ]]; then
      r_extra=" + $((r_total - 5)) more"
    fi

    msg="${msg}"$'\n'"*${r_name}* (${r_platform})"
    msg="${msg}"$'\n'"📅 ${r_date} | 👥 ${r_party} people"
    msg="${msg}"$'\n'"🕐 ${r_times}${r_extra}"
    msg="${msg}"$'\n'"🔗 [Book now](${r_url})"$'\n'
  done

  bash "${SCRIPTS_DIR}/notify.sh" "$msg" 2>/dev/null || true
fi

# Notify about OpenTable session expiry
if [[ "$ot_session_expired" == "true" ]]; then
  bash "${SCRIPTS_DIR}/notify.sh" "⚠️ OpenTable session expired. Run to re-authenticate:
node ~/.openclaw/skills/resy-hunter/scripts/opentable-login.js" 2>/dev/null || true
fi


path: scripts/notify.sh
content: #!/usr/bin/env bash
# notify.sh — Send a Telegram notification
# Usage: ./notify.sh "<message>"
#
# Pulls the bot token from OpenClaw's Telegram channel config.
# Falls back to TELEGRAM_BOT_TOKEN env var if config not found.

set -euo pipefail

if [[ $# -lt 1 ]]; then
  echo '{"error": "Usage: notify.sh <message>"}' >&2
  exit 1
fi

MESSAGE="$1"

if [[ -z "${TELEGRAM_CHAT_ID:-}" ]]; then
  echo '{"error": "TELEGRAM_CHAT_ID is not set"}' >&2
  exit 1
fi
CHAT_ID="${TELEGRAM_CHAT_ID}"

# Resolve bot [REDACTED_SECRET] config → env var fallback
BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-}"

if [[ -z "$BOT_TOKEN" ]]; then
  # Search OpenClaw config files for channels.telegram.botToken
  for cfg in \
    "$HOME/.openclaw/config.json" \
    "$HOME/.openclaw/openclaw.json" \
    "$HOME/.openclaw/gateway.json" \
    "$HOME/.config/openclaw/config.json" \
    "$HOME/.openclaw/config.yaml" \
    "$HOME/.openclaw/openclaw.yaml"; do
    if [[ -f "$cfg" ]]; then
      # Try JSON first
      if [[ "$cfg" == *.json ]]; then
        token=$(jq -r '.channels.telegram.botToken // empty' "$cfg" 2>/dev/null || true)
        if [[ -n "$token" ]]; then
          BOT_TOKEN="$token"
          break
        fi
      fi
      # Try YAML (grep-based, no yq dependency)
      if [[ "$cfg" == *.yaml || "$cfg" == *.yml ]]; then
        [REDACTED_SECRET] -A1 'botToken' "$cfg" 2>/dev/null | grep -oP ':\s*\K\S+' | tr -d '"'"'" || true)
        if [[ -n "$token" ]]; then
          BOT_TOKEN="$token"
          break
        fi
      fi
    fi
  done
fi

if [[ -z "$BOT_TOKEN" ]]; then
  echo '{"error": "Telegram bot token not found. Check OpenClaw config (channels.telegram.botToken) or set TELEGRAM_BOT_TOKEN."}' >&2
  exit 1
fi

response=$(curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
  -H "Content-Type: application/json" \
  -d "$(jq -n \
    --arg chat_id "$CHAT_ID" \
    --arg text "$MESSAGE" \
    '{chat_id: $chat_id, text: $text, parse_mode: "Markdown"}')")

ok=$(echo "$response" | jq -r '.ok // false')

if [[ "$ok" != "true" ]]; then
  echo "$response" | jq '{error: "Telegram send failed", details: .}' >&2
  exit 1
fi

echo "$response" | jq '{ok: true, message_id: .result.message_id}'


path: scripts/opentable-check.js
content: #!/usr/bin/env node
// opentable-check.js — Query OpenTable availability using Playwright
// Usage: node opentable-check.js <restaurant_id> <date> <party_size> [time]
// Output: JSON with restaurant info and available slots
//
// Uses saved session from opentable-login.js (manual login flow).
// Session file: ~/.openclaw/data/resy-hunter/opentable-session.json
// If session is missing or expired, returns {session_expired: true}.

const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

const DATA_DIR = path.join(process.env.HOME, '.openclaw/data/resy-hunter');
const SESSION_FILE = path.join(DATA_DIR, 'opentable-session.json');

function formatTo12h(timeStr) {
  const match = timeStr.match(/(\d{1,2}):(\d{2})/);
  if (!match) return { display: timeStr, time_24h: '00:00' };
  const h = parseInt(match[1]);
  const m = match[2];
  const time_24h = `${String(h).padStart(2, '0')}:${m}`;
  let display;
  if (h === 0) display = `12:${m} AM`;
  else if (h < 12) display = `${h}:${m} AM`;
  else if (h === 12) display = `12:${m} PM`;
  else display = `${h - 12}:${m} PM`;
  return { display, time_24h };
}

async function main() {
  const args = process.argv.slice(2);
  if (args.length < 3) {
    console.error(JSON.stringify({ error: 'Usage: opentable-check.js <restaurant_id> <date> <party_size> [time]' }));
    process.exit(1);
  }

  const restaurantId = args[0];
  const date = args[1];
  const partySize = parseInt(args[2], 10);
  const time = args[3] || '19:00';

  // Check for saved session
  if (!fs.existsSync(SESSION_FILE)) {
    console.log(JSON.stringify({
      platform: 'opentable',
      restaurant_id: restaurantId,
      date: date,
      party_size: partySize,
      slots: [],
      session_expired: true,
      message: 'No OpenTable session found. Run opentable-login.js to authenticate.',
    }));
    return;
  }

  let browser;
  try {
    browser = await chromium.launch({
      headless: true,
      args: [
        '--disable-blink-features=AutomationControlled',
        '--no-sandbox',
      ],
    });

    // Load saved session
    const context = await browser.newContext({
      userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
      viewport: { width: 1280, height: 800 },
      storageState: SESSION_FILE,
    });

    const page = await context.newPage();

    // Collect availability data from network responses
    let availabilityData = null;

    page.on('response', async (response) => {
      const url = response.url();
      if (url.includes('/availability') || url.includes('/dtp') || url.includes('timeslots')) {
        try {
          const contentType = response.headers()['content-type'] || '';
          if (contentType.includes('json') && response.status() === 200) {
            const data = await response.json();
            if (!availabilityData) {
              availabilityData = data;
            }
          }
        } catch (e) {
          // Non-JSON response, skip
        }
      }
    });

    // Build the OpenTable URL
    const dateTime = `${date}T${time}`;
    const otUrl = `https://www.opentable.com/restref/client/?rid=${restaurantId}&restref=${restaurantId}&datetime=${dateTime}&covers=${partySize}&searchdatetime=${dateTime}&partysize=${partySize}`;

    await page.goto(otUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });

    // Check if session expired (login wall detected)
    await page.waitForTimeout(3000);
    const sessionExpired = await detectLoginWall(page);

    if (sessionExpired) {
      console.log(JSON.stringify({
        platform: 'opentable',
        restaurant_id: restaurantId,
        date: date,
        party_size: partySize,
        slots: [],
        session_expired: true,
        message: 'OpenTable session expired. Run opentable-login.js to re-authenticate.',
      }));
      await browser.close();
      return;
    }

    // Wait for availability to load
    await page.waitForTimeout(5000);

    // Extract slots
    let slots = [];

    if (availabilityData) {
      slots = parseAvailabilityResponse(availabilityData, date);
    }

    if (slots.length === 0) {
      slots = await extractSlotsFromDOM(page, date);
    }

    // Refresh session for next run
    try {
      await context.storageState({ path: SESSION_FILE });
    } catch (e) {
      // Non-critical
    }

    // Convert times to 12-hour format
    slots = slots.map(s => {
      const fmt = formatTo12h(s.time_start);
      return { ...s, time_start: fmt.display, time_24h: fmt.time_24h };
    });

    const output = {
      platform: 'opentable',
      restaurant_id: restaurantId,
      date: date,
      party_size: partySize,
      slots: slots,
      url: `https://www.opentable.com/restref/client/?rid=${restaurantId}&restref=${restaurantId}&datetime=${dateTime}&covers=${partySize}`,
    };

    console.log(JSON.stringify(output, null, 2));
  } catch (err) {
    console.error(JSON.stringify({
      error: `OpenTable check failed: ${err.message}`,
      platform: 'opentable',
      restaurant_id: restaurantId,
      date: date,
      party_size: partySize,
      slots: [],
    }));
    process.exit(1);
  } finally {
    if (browser) await browser.close();
  }
}

async function detectLoginWall(page) {
  try {
    const isOnLogin = page.url().includes('login') || page.url().includes('signin') || page.url().includes('/auth/');
    if (isOnLogin) return true;

    const loginForm = await page.$('input[type="email"], input[name="email"], #Email');
    const loginButton = await page.$('button[data-test="login-button"], a[href*="login"], #login-button, [data-test="sign-in"]');
    if (loginForm && loginButton) return true;

    return false;
  } catch (e) {
    return false;
  }
}

function parseAvailabilityResponse(data, date) {
  const slots = [];

  try {
    if (data.availability && Array.isArray(data.availability)) {
      for (const slot of data.availability) {
        const timeStr = slot.datetime || slot.time || slot.dateTime;
        if (timeStr) {
          slots.push({
            time_start: timeStr,
            type: slot.type || slot.areaName || 'Standard',
          });
        }
      }
    }

    if (data.times && Array.isArray(data.times)) {
      for (const slot of data.times) {
        slots.push({
          time_start: slot.dateTime || slot.time || `${date}T${slot.timeString}`,
          type: slot.type || slot.experienceType || 'Standard',
        });
      }
    }

    if (data.data) {
      const nested = data.data.availability || data.data.times || data.data.timeslots;
      if (Array.isArray(nested)) {
        for (const slot of nested) {
          const timeStr = slot.datetime || slot.dateTime || slot.time;
          if (timeStr) {
            slots.push({
              time_start: timeStr,
              type: slot.type || slot.areaName || 'Standard',
            });
          }
        }
      }
    }

    if (data.timeslots && Array.isArray(data.timeslots)) {
      for (const slot of data.timeslots) {
        slots.push({
          time_start: slot.dateTime || slot.time,
          type: slot.experienceType || slot.type || 'Standard',
        });
      }
    }
  } catch (e) {
    // Parsing failed
  }

  return slots;
}

async function extractSlotsFromDOM(page, date) {
  const slots = [];

  try {
    const slotData = await page.evaluate((targetDate) => {
      const results = [];
      const timeElements = document.querySelectorAll(
        '[data-test*="time"], [class*="timeslot"], [class*="time-slot"], ' +
        'button[class*="slot"], a[class*="slot"], ' +
        '[data-test="availability-time"], [class*="TimeSlot"], ' +
        'button[data-time], [data-timeslot]'
      );

      for (const el of timeElements) {
        const timeText = el.textContent?.trim();
        const dataTime = el.getAttribute('data-time') || el.getAttribute('data-timeslot') || el.getAttribute('data-datetime');

        if (timeText || dataTime) {
          let time = dataTime || '';
          if (!time && timeText) {
            const match = timeText.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
            if (match) {
              let hours = parseInt(match[1]);
              const mins = match[2];
              const period = match[3].toUpperCase();
              if (period === 'PM' && hours !== 12) hours += 12;
              if (period === 'AM' && hours === 12) hours = 0;
              time = `${targetDate}T${String(hours).padStart(2, '0')}:${mins}`;
            }
          }

          if (time) {
            const parent = el.closest('[class*="area"], [class*="section"], [data-test*="area"]');
            const type = parent?.getAttribute('data-area') || parent?.querySelector('[class*="area-name"], [class*="section-title"]')?.textContent?.trim() || 'Standard';

            results.push({
              time_start: time,
              type: type,
            });
          }
        }
      }

      const seen = new Set();
      return results.filter((s) => {
        const key = s.time_start;
        if (seen.has(key)) return false;
        seen.add(key);
        return true;
      });
    }, date);

    slots.push(...slotData);
  } catch (e) {
    // DOM extraction failed
  }

  return slots;
}

main();


path: scripts/opentable-login.js
content: #!/usr/bin/env node
// opentable-login.js — Manual login flow for OpenTable
// Usage: node opentable-login.js
//
// Opens a VISIBLE browser window to OpenTable's login page.
// Log in manually (email + OTP). The script detects when login
// is complete and saves session cookies for headless reuse.
//
// Session saved to: ~/.openclaw/data/resy-hunter/opentable-session.json

const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

const DATA_DIR = path.join(process.env.HOME, '.openclaw/data/resy-hunter');
const SESSION_FILE = path.join(DATA_DIR, 'opentable-session.json');

async function main() {
  fs.mkdirSync(DATA_DIR, { recursive: true });

  console.log('🔐 Opening OpenTable login page...');
  console.log('   Log in with your email and complete the OTP verification.');
  console.log('   The browser will close automatically when login is detected.\n');

  const browser = await chromium.launch({
    headless: false,
    args: ['--no-sandbox'],
  });

  const context = await browser.newContext({
    viewport: { width: 1280, height: 800 },
  });

  const page = await context.newPage();

  // Navigate to profile page — will redirect to login if not authenticated
  await page.goto('https://www.opentable.com/my/profile', {
    waitUntil: 'domcontentloaded',
    timeout: 30000,
  });

  console.log('⏳ Waiting for you to log in (5 minute timeout)...');

  let loggedIn = false;
  const maxWait = 300000; // 5 minutes
  const startTime = Date.now();

  while (!loggedIn && (Date.now() - startTime) < maxWait) {
    await page.waitForTimeout(2000);

    loggedIn = await page.evaluate(() => {
      const url = window.location.href;
      // Detect successful login: on profile page, or see avatar/sign-out elements
      const onProfile = url.includes('/my/') && !url.includes('login') && !url.includes('signin');
      const hasAvatar = !!(
        document.querySelector('[data-test="user-avatar"]') ||
        document.querySelector('[class*="avatar"]') ||
        document.querySelector('[class*="profile-icon"]')
      );
      const hasSignOut = !!(
        document.querySelector('a[href*="logout"]') ||
        document.querySelector('[data-test="sign-out"]')
      );
      return onProfile || hasAvatar || hasSignOut;
    });

    // Also check if we navigated away from login page
    if (!loggedIn) {
      const currentUrl = page.url();
      if (!currentUrl.includes('login') && !currentUrl.includes('signin') && !currentUrl.includes('auth')) {
        // Give it a moment to settle
        await page.waitForTimeout(2000);
        loggedIn = true;
      }
    }
  }

  if (!loggedIn) {
    console.log('❌ Login timed out (5 minutes). Try again.');
    await browser.close();
    process.exit(1);
  }

  // Save session
  await context.storageState({ path: SESSION_FILE });
  console.log(`\n✅ Login successful! Session saved.`);
  console.log(`   Path: ${SESSION_FILE}`);
  console.log('   Future OpenTable checks will use this session automatically.');

  await browser.close();
}

main().catch(err => {
  console.error(`❌ Error: ${err.message}`);
  process.exit(1);
});


path: scripts/resy-auth.sh
content: #!/usr/bin/env bash
# resy-auth.sh — Authenticate with Resy and return a fresh auth token
# Usage: ./resy-auth.sh [--force]
# Output: auth token string (stdout)
#
# Caches the token to ~/.openclaw/data/resy-hunter/.resy-token
# Returns cached token if less than 12 hours old (unless --force)
#
# Requires: RESY_API_KEY, RESY_EMAIL, RESY_PASSWORD

set -euo pipefail

SKILL_DIR="$HOME/.openclaw/skills/resy-hunter"
DATA_DIR="$HOME/.openclaw/data/resy-hunter"
mkdir -p "$DATA_DIR"

# Migrate old token file if it exists
if [[ -f "${SKILL_DIR}/.resy-token" && ! -f "${DATA_DIR}/.resy-token" ]]; then
  cp "${SKILL_DIR}/.resy-token" "${DATA_DIR}/.resy-token"
fi

TOKEN_FILE="${DATA_DIR}/.resy-token"
MAX_AGE_SECONDS=43200  # 12 hours

FORCE=false
if [[ "${1:-}" == "--force" ]]; then
  FORCE=true
fi

if [[ -z "${RESY_API_KEY:-}" ]]; then
  echo '{"error": "RESY_API_KEY is not set"}' >&2
  exit 1
fi

if [[ -z "${RESY_EMAIL:-}" ]]; then
  echo '{"error": "RESY_EMAIL is not set"}' >&2
  exit 1
fi

if [[ -z "${RESY_PASSWORD:-}" ]]; then
  echo '{"error": "RESY_PASSWORD is not set"}' >&2
  exit 1
fi

# Check for cached token
if [[ "$FORCE" == "false" && -f "$TOKEN_FILE" ]]; then
  cached_token=$(jq -r '.token // empty' "$TOKEN_FILE" 2>/dev/null || true)
  cached_at=$(jq -r '.cached_at // 0' "$TOKEN_FILE" 2>/dev/null || echo "0")
  now=$(date +%s)

  if [[ -n "$cached_token" && "$cached_at" != "0" ]]; then
    age=$(( now - cached_at ))
    if [[ $age -lt $MAX_AGE_SECONDS ]]; then
      echo "$cached_token"
      exit 0
    fi
  fi
fi

# Authenticate with Resy
auth_header='Authorization: ResyAPI api_key="'"${RESY_API_KEY}"'"'
response=$(curl -s -w "\n%{http_code}" -X POST "https://api.resy.com/3/auth/password" \
  -H "${auth_header}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "email=${RESY_EMAIL}" \
  --data-urlencode "[REDACTED_SECRET]")

http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')

if [[ "$http_code" == "401" || "$http_code" == "403" ]]; then
  echo '{"error": "Resy login failed (HTTP '"$http_code"'). Check RESY_EMAIL and RESY_PASSWORD."}' >&2
  exit 1
fi

if [[ "$http_code" != "200" ]]; then
  echo "{\"error\": \"Resy auth returned HTTP ${http_code}\", \"body\": $(echo "$body" | jq -Rs .)}" >&2
  exit 1
fi

# Extract the token
[REDACTED_SECRET] "$body" | jq -r '.token // empty')

if [[ -z "$token" ]]; then
  echo '{"error": "No token in Resy auth response", "body": '"$body"'}' >&2
  exit 1
fi

# Cache the token
now=$(date +%s)
jq -n --arg token "$token" --argjson cached_at "$now" \
  '{[REDACTED_SECRET], cached_at: $cached_at}' > "$TOKEN_FILE"

echo "$token"


path: scripts/resy-check.sh
content: #!/usr/bin/env bash
# resy-check.sh — Query Resy availability for a single venue
# Usage: ./resy-check.sh <venue_id> <date> <party_size>
# Output: JSON with venue info and available slots
#
# Auto-authenticates via resy-auth.sh if RESY_AUTH_TOKEN is not already set.
# On HTTP 419 (token expired), forces a re-auth and retries once.

set -euo pipefail

SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPTS_DIR")"
DATA_DIR="$HOME/.openclaw/data/resy-hunter"

if [[ $# -lt 3 ]]; then
  echo '{"error": "Usage: resy-check.sh <venue_id> <date> <party_size>"}' >&2
  exit 1
fi

VENUE_ID="$1"
DATE="$2"
PARTY_SIZE="$3"

if [[ -z "${RESY_API_KEY:-}" ]]; then
  echo '{"error": "RESY_API_KEY is not set"}' >&2
  exit 1
fi

# Auto-authenticate if no token is set
if [[ -z "${RESY_AUTH_TOKEN:-}" ]]; then
  RESY_AUTH_TOKEN=$(bash "${SCRIPTS_DIR}/resy-auth.sh" 2>&1) || {
    echo "$RESY_AUTH_TOKEN" >&2
    exit 1
  }
fi

_resy_find() {
  local auth_header='Authorization: ResyAPI api_key="'"${RESY_API_KEY}"'"'
  local payload='{"lat":0,"long":0,"day":"'"${DATE}"'","party_size":'"${PARTY_SIZE}"',"venue_id":'"${VENUE_ID}"'}'
  curl -s -w "\n%{http_code}" -X POST "https://api.resy.com/4/find" \
    -H "${auth_header}" \
    -H "x-resy-auth-[REDACTED_SECRET]" \
    -H "Content-Type: application/json" \
    -d "${payload}"
}

response=$(_resy_find)
http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')

# On 419 (token expired), force re-auth and retry once
if [[ "$http_code" == "419" ]]; then
  rm -f "${DATA_DIR}/.resy-token"
  RESY_AUTH_TOKEN=$(bash "${SCRIPTS_DIR}/resy-auth.sh" --force 2>&1) || {
    echo "$RESY_AUTH_TOKEN" >&2
    exit 1
  }
  response=$(_resy_find)
  http_code=$(echo "$response" | tail -1)
  body=$(echo "$response" | sed '$d')
fi

if [[ "$http_code" != "200" ]]; then
  echo "{\"error\": \"Resy API returned HTTP ${http_code}\", \"body\": $(echo "$body" | jq -Rs .)}" >&2
  exit 1
fi

echo "$body" | jq --arg date "$DATE" --argjson party_size "$PARTY_SIZE" '
def to12h:
  split(":") | (.[0] | tonumber) as $h | .[1] as $m |
  if $h == 0 then "12:\($m) AM"
  elif $h < 12 then "\($h):\($m) AM"
  elif $h == 12 then "12:\($m) PM"
  else "\($h - 12):\($m) PM"
  end;
def get_24h:
  split(" ") | last | split("T") | last | split(":") | .[0:2] | join(":");
def fmt_time:
  get_24h | to12h;
{
  platform: "resy",
  venue_id: (.results.venues[0].venue.id.resy // null),
  venue_name: (.results.venues[0].venue.name // "unknown"),
  venue_slug: (.results.venues[0].venue.slug // .results.venues[0].venue.url_slug // null),
  venue_city: (.results.venues[0].venue.location.city // null),
  venue_region: (.results.venues[0].venue.location.region // null),
  date: $date,
  party_size: $party_size,
  slots: [
    (.results.venues[0].slots // [])[] | {
      time_start: (.date.start | fmt_time),
      time_end: (.date.end | fmt_time),
      time_24h: (.date.start | get_24h),
      type: .config.type,
      config_token: .config.token,
      deposit_fee: (.payment.deposit_fee // null)
    }
  ]
}'


path: scripts/resy-search-venue.sh
content: #!/usr/bin/env bash
# resy-search-venue.sh — Search Resy for a venue by name
# Usage: ./resy-search-venue.sh <query> [lat] [long]
# Output: JSON array of matching venues with venue_id, name, slug, location
#
# Auto-authenticates via resy-auth.sh if RESY_AUTH_TOKEN is not already set.

set -euo pipefail

SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"

if [[ $# -lt 1 ]]; then
  echo '{"error": "Usage: resy-search-venue.sh <query> [lat] [long]"}' >&2
  exit 1
fi

QUERY="$1"
LAT="${2:-40.7128}"
LONG="${3:--74.0060}"

if [[ -z "${RESY_API_KEY:-}" ]]; then
  echo '{"error": "RESY_API_KEY is not set"}' >&2
  exit 1
fi

# Auto-authenticate if no token is set
if [[ -z "${RESY_AUTH_TOKEN:-}" ]]; then
  RESY_AUTH_TOKEN=$(bash "${SCRIPTS_DIR}/resy-auth.sh" 2>&1) || {
    echo "$RESY_AUTH_TOKEN" >&2
    exit 1
  }
fi

auth_header='Authorization: ResyAPI api_key="'"${RESY_API_KEY}"'"'
response=$(curl -s -w "\n%{http_code}" -G "https://api.resy.com/3/venuesearch/search" \
  --data-urlencode "query=${QUERY}" \
  --data-urlencode "geo.lat=${LAT}" \
  --data-urlencode "geo.long=${LONG}" \
  --data-urlencode "per_page=10" \
  --data-urlencode "types=venue" \
  -H "${auth_header}")

http_code=$(echo "$response" | tail -1)
body=$(echo "$response" | sed '$d')

if [[ "$http_code" != "200" ]]; then
  # Fallback: try the /4/find endpoint with location search
  future_date=$(date -v+7d +%Y-%m-%d 2>/dev/null || date +%Y-%m-%d -d '+7 days')
  fallback_payload='{"lat":'"${LAT}"',"long":'"${LONG}"',"day":"'"${future_date}"'","party_size":2}'
  response2=$(curl -s -w "\n%{http_code}" -X POST "https://api.resy.com/4/find" \
    -H "${auth_header}" \
    -H "Content-Type: application/json" \
    -d "${fallback_payload}")

  http_code2=$(echo "$response2" | tail -1)
  body2=$(echo "$response2" | sed '$d')

  if [[ "$http_code2" != "200" ]]; then
    echo "{\"error\": \"Venue search failed (HTTP ${http_code}). Try finding the venue_id from the restaurant's Resy URL.\"}" >&2
    exit 1
  fi

  echo "$body2" | jq --arg query "$QUERY" '[
    .results.venues[]
    | select(.venue.name | ascii_downcase | contains($query | ascii_downcase))
    | {
        venue_id: .venue.id.resy,
        name: .venue.name,
        slug: .venue.slug,
        city: .venue.location.city,
        neighborhood: .venue.location.neighborhood
      }
  ]'
  exit 0
fi

echo "$body" | jq '[
  .search.hits[]
  | {
      venue_id: .id.resy,
      name: .name,
      slug: .url_slug,
      city: (.location.city // null),
      neighborhood: (.location.neighborhood // null)
    }
]'


path: watchlist.json
content: {
  "restaurants": []
}


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