diff --git a/Bash/claude-statusline.sh b/Bash/claude-statusline.sh new file mode 100644 index 0000000..3223e53 --- /dev/null +++ b/Bash/claude-statusline.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# claude-statusline.sh - live Claude Code status line: plan usage, context, cost, churn. +# +# Description: Renders one status line for Claude Code. Reads the Status JSON on +# stdin and the session transcript for token counts. Shows the same +# 5h/weekly plan-usage percentages as /usage (when present), plus +# context-window fill, session cost and lines changed. Needs jq. +# No strict mode by design: missing fields must degrade gracefully. +# Usage: Wire up in ~/.claude/settings.json: +# "statusLine": { "type": "command", +# "command": "bash /path/to/Scripts/Bash/claude-statusline.sh" } +# Author: Sebastian GrΓ€f +# Repo: https://github.com/segraef/Scripts + +input=$(cat) + +model_name=$(printf '%s' "$input" | jq -r '.model.display_name // "Claude"') +model_id=$(printf '%s' "$input" | jq -r '.model.id // ""') +transcript=$(printf '%s' "$input" | jq -r '.transcript_path // ""') +cost=$(printf '%s' "$input" | jq -r '.cost.total_cost_usd // 0') +added=$(printf '%s' "$input" | jq -r '.cost.total_lines_added // 0') +removed=$(printf '%s' "$input" | jq -r '.cost.total_lines_removed // 0') +cwd=$(printf '%s' "$input" | jq -r '.workspace.current_dir // .cwd // ""') +# Reasoning effort (/effort). Only present when the model supports it, so the +# whole segment is omitted rather than printing an empty label. Note the field +# reports the underlying tier: an "ultracode" session shows as xhigh. +effort=$(printf '%s' "$input" | jq -r '.effort.level // empty') + +# Plan rate-limit usage β€” the same numbers /usage shows. Present only for +# Claude.ai Pro/Max sessions, and only after the first API response. +h5=$(printf '%s' "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty' | cut -d. -f1) +wk=$(printf '%s' "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty' | cut -d. -f1) + +# Write the two percentages down as well as drawing them, so an agent session +# can read its own budget. The CLI hands these to this script on stdin and keeps +# no live copy: the one on disk (~/.claude.json cachedUsageUtilization) is a +# cache that has been seen two days stale, reading 74% against a real 92%, which +# is worse than having nothing. This file is rewritten every render. +if [ -n "$h5" ] || [ -n "$wk" ]; then + printf '{"five_hour_pct":%s,"seven_day_pct":%s,"written_at":"%s"}\n' \ + "${h5:-null}" "${wk:-null}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + > "$HOME/.claude/usage-live.json" 2>/dev/null || true +fi + +# Colour a usage percentage by pressure. Usage: colour_for +colour_for() { + if [ "$1" -ge 90 ]; then printf '\033[31m' # red + elif [ "$1" -ge 70 ]; then printf '\033[33m' # yellow + else printf '\033[32m'; fi # green +} + +# Context window size as Claude Code reports it. A 1M session can carry a model +# ID without "1m", so the ID guess is only the fallback for versions without it. +window=$(printf '%s' "$input" | jq -r '.context_window.context_window_size // empty | select(. > 0)') +if [ -z "$window" ]; then + case "$model_id" in + *1m*) window=1000000 ;; # also matches "[1m]" + *) window=200000 ;; + esac +fi + +# Latest context size = last assistant usage in the transcript. +used=0 +if [ -n "$transcript" ] && [ -f "$transcript" ]; then + reverse_cmd="tail -r"; command -v tac >/dev/null 2>&1 && reverse_cmd="tac" + used=$($reverse_cmd "$transcript" 2>/dev/null | jq -r ' + select(.message.usage != null) + | (.message.usage.input_tokens // 0) + + (.message.usage.cache_read_input_tokens // 0) + + (.message.usage.cache_creation_input_tokens // 0)' 2>/dev/null \ + | head -n1) + [ -z "$used" ] && used=0 +fi + +pct=$(( used * 100 / window )) +[ "$pct" -gt 100 ] && pct=100 + +# Colour the context segment by pressure. +if [ "$pct" -ge 85 ]; then cx="\033[31m" # red +elif [ "$pct" -ge 60 ]; then cx="\033[33m" # yellow +else cx="\033[32m"; fi # green +dim="\033[2m"; rst="\033[0m" + +usedk=$(( used / 1000 )) +wink=$(( window / 1000 )) + +dir=$(basename "$cwd") +costfmt=$(printf '%.2f' "$cost") + +# Effort segment. Colour rises with the tier so a costly setting is visible at a +# glance rather than something you have to remember you left on. +effort_seg="" +if [ -n "$effort" ]; then + case "$effort" in + max) ec="\033[35m" ;; # magenta + xhigh) ec="\033[31m" ;; # red + high) ec="\033[33m" ;; # yellow + medium) ec="\033[32m" ;; # green + *) ec="\033[2m" ;; # dim for low + esac + effort_seg="${ec}${effort}${rst} ${dim}|${rst} " +fi + +# Build the plan-usage segment when the data is present. +usage_seg="" +if [ -n "$h5" ] || [ -n "$wk" ]; then + [ -z "$h5" ] && h5=0 + [ -z "$wk" ] && wk=0 + c5=$(colour_for "$h5"); cw=$(colour_for "$wk") + usage_seg="${c5}5h ${h5}%${rst} ${dim}Β·${rst} ${cw}wk ${wk}%${rst} ${dim}|${rst} " +fi + +printf "%b %s${dim}|${rst} %b%b${cx}ctx %s%% (%dk/%dk)${rst} ${dim}|${rst} \$%s ${dim}|${rst} ${dim}+%s/-%s${rst} ${dim}%s${rst}" \ + "🧠" "$model_name" "$effort_seg" "$usage_seg" "$pct" "$usedk" "$wink" "$costfmt" "$added" "$removed" "$dir" diff --git a/Bash/wifi-monitor.sh b/Bash/wifi-monitor.sh new file mode 100644 index 0000000..5e12438 --- /dev/null +++ b/Bash/wifi-monitor.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# +# wifi-monitor.sh - log Wi-Fi drops on macOS, and work around DHCP renewals that never succeed. +# +# Description: Checks the Wi-Fi link every few seconds and logs each drop with its cause and +# duration, plus macOS's own Wi-Fi/DHCP log around it. "report" rebuilds the drop +# history from that system log alone, no monitor needed. +# "fix-on" is a workaround for networks where the Mac's address renewal never gets +# an answer (seen with a security product's network filter dropping the renewal +# retries): Wi-Fi is switched off and on shortly before a short lease runs out, +# which costs a few seconds instead of minutes without an address. +# macOS only. No strict mode by design: the loops must survive failing checks. +# Logs go to $WIFI_MONITOR_DIR (default ~/wifi-monitor), never next to the script. +# Usage: ./wifi-monitor.sh start run in the background (keeps the Mac awake) +# ./wifi-monitor.sh run run in this terminal, Ctrl+C to stop +# ./wifi-monitor.sh status drops so far in the latest run +# ./wifi-monitor.sh stop stop the background run and print its summary +# ./wifi-monitor.sh report [6h] drop history from macOS's own log (default 24h) +# ./wifi-monitor.sh fix-on|fix-off|fix-status the auto-rejoin workaround +# ./wifi-monitor.sh shortcut put a "Wi-Fi Fix" app on the Desktop that turns it on/off +# Author: Sebastian GrΓ€f +# Repo: https://github.com/segraef/Scripts +# +# Each check names the first failing state: +# WIFI_DISCONNECTED not joined to any Wi-Fi network +# NO_IP_ADDRESS joined, but no address from DHCP (or a self-assigned 169.254.x) +# ROUTER_UNREACHABLE has an address, but neither the router nor the internet answers +# INTERNET_DOWN router answers, internet does not (problem upstream of the router) +# WEB_FAILING internet answers ping, a web page does not load (DNS or a login page) +# A run writes run-/ with samples.csv (one row per check), events.log (one line +# per state change, with how long each drop lasted) and syslog-*.txt (system log per drop). + +IF=$(networksetup -listallhardwareports | awk '/Wi-Fi/{getline; print $2; exit}') +BASE="${WIFI_MONITOR_DIR:-$HOME/wifi-monitor}" +mkdir -p "$BASE" +PIDFILE="$BASE/monitor.pid" +INTERVAL=2 # seconds between checks +REJOIN_EARLY=150 # fix: rejoin this many seconds before the lease ends +MAX_LEASE=3600 # fix: only act on leases this short; normal multi-hour leases are left alone + +note() { echo "$(date '+%F %T') $*" | tee -a "$EV"; } + +ms() { # round-trip ms of one ping, empty if no reply + ping -n -c1 -t1 "$1" 2>/dev/null | sed -n 's/.*time=\([0-9.]*\).*/\1/p' +} + +router_ok() { # many routers ignore ping, so an open DNS port counts as an answer too + [ -n "$1" ] && { nc -z -G1 -w1 "$1" 53 >/dev/null 2>&1 || ping -n -c1 -t1 "$1" >/dev/null 2>&1; } +} + +lease_age_min() { + local s + s=$(ipconfig getsummary "$IF" 2>/dev/null | awk -F' : ' '/LeaseStartTime/{print $2; exit}') + [ -n "$s" ] && s=$(date -j -f '%m/%d/%Y %H:%M:%S' "$s" +%s 2>/dev/null) && echo $(( ($(date +%s) - s) / 60 )) +} + +evidence() { # $1 run dir, $2 epoch the drop started; grabs 35 min back to cover a whole short lease + /usr/bin/log show --start "$(date -r $(($2 - 2100)) '+%F %T')" --style compact --predicate \ + "(process == \"configd\" AND subsystem == \"com.apple.IPConfiguration\" AND eventMessage CONTAINS \"$IF\") OR (process == \"airportd\" AND (eventMessage CONTAINS \"DISASSOC\" OR eventMessage CONTAINS \"linkDown\"))" \ + > "$1/syslog-$(date -r "$2" +%H%M%S).txt" 2>&1 +} + +summary() { # $1 run dir + local ev="$1/events.log" + echo "== $(basename "$1"): $(grep ' DROP #' "$ev" | grep -vc planned) drops, $(awk '/ BACK #/ && !/planned/{for(i=1;i<=NF;i++) if($i=="after") s+=$(i+1)} END{print s+0}' "$ev")s without working internet (planned auto-rejoins not counted) ==" + grep -E ' (DROP|BACK) #|GAP' "$ev" || true +} + +# shellcheck disable=SC2012 # run-* names are our own timestamps, ls sorts them oldest first +latest() { ls -d "$BASE"/run-* 2>/dev/null | tail -1; } + +alive() { [ -f "$1" ] && kill -0 "$(cat "$1")" 2>/dev/null; } # $1 pid file + +run() { + alive "$PIDFILE" && { echo "already running (pid $(cat "$PIDFILE"))"; exit 1; } + local dir + dir="$BASE/run-$(date +%Y%m%d-%H%M%S)" + mkdir -p "$dir"; echo $$ > "$PIDFILE" + EV="$dir/events.log"; CSV="$dir/samples.csv" + caffeinate -i -w $$ & # no idle sleep while this runs (closing the lid on battery still sleeps) + trap 'note "stopped"; summary "$dir"; rm -f "$PIDFILE"; exit 0' INT TERM + echo "time,state,wifi_link,ip,router,router_ok,internet_ms,web,lease_age_min,router_hw" > "$CSV" + note "monitoring $IF every ${INTERVAL}s, evidence in $dir" + local prev=OK n=0 down=0 seq='' pl='' last router='' now link ip r rok ims web hw st la + last=$(date +%s) + while :; do + now=$(date +%s) + [ $((now - last)) -gt 30 ] && note "GAP: no checks for $((now - last))s (Mac asleep?)" + last=$now + link=$(ifconfig "$IF" 2>/dev/null | awk '/status:/{print $2}') + ip=$(ipconfig getifaddr "$IF" 2>/dev/null) + r=$(ipconfig getoption "$IF" router 2>/dev/null); [ -n "$r" ] && router=$r # remember it after the lease is gone + hw=''; [ -n "$router" ] && hw=$(arp -n "$router" 2>/dev/null | awk '{print $4}') + rok='' ims='' web='' st=OK + if [ "$link" != active ]; then st=WIFI_DISCONNECTED + elif [ -z "$ip" ] || [[ $ip == 169.254.* ]]; then st=NO_IP_ADDRESS + else + if router_ok "$router"; then rok=yes; else rok=no; fi + ims=$(ms 1.1.1.1); [ -z "$ims" ] && ims=$(ms 8.8.8.8) # second target so one lost packet is not a drop + if [ -z "$ims" ]; then + if [ $rok = yes ]; then st=INTERNET_DOWN; else st=ROUTER_UNREACHABLE; fi + elif curl -s -m 3 http://captive.apple.com/hotspot-detect.html | grep -q Success; then web=ok + else web=fail; st=WEB_FAILING + fi + fi + la=$(lease_age_min) + echo "$(date '+%F %T'),$st,$link,$ip,$router,$rok,$ims,$web,$la,$hw" >> "$CSV" + if [ "$st" != "$prev" ]; then + if [ "$prev" = OK ]; then + n=$((n + 1)); down=$now; seq=$st; pl='' + [ -f "$BASE/rejoin.stamp" ] && [ $((now - $(stat -f %m "$BASE/rejoin.stamp"))) -lt 30 ] && pl=" (planned auto-rejoin)" + note "DROP #$n$pl: $st | lease age ${la:-?} min | router $router hw ${hw:-?}" + elif [ "$st" = OK ]; then + note "BACK #$n$pl after $((now - down))s | $seq > OK" + evidence "$dir" "$down" & + else + seq="$seq > $st"; note " #$n now $st | lease age ${la:-?} min | router hw ${hw:-?}" + fi + prev=$st + fi + sleep "$INTERVAL" + done +} + +fixloop() { # a fresh join gets an address in seconds even where renewals fail, so rejoin before the lease runs out + alive "$BASE/fix.pid" && { echo "already on (pid $(cat "$BASE/fix.pid"))"; exit 1; } + EV="$BASE/fix.log"; echo $$ > "$BASE/fix.pid" + caffeinate -i -w $$ & + local la lt a miss=0 off='' + trap '[ -n "$off" ] && networksetup -setairportpower "$IF" on; note "auto-rejoin off"; rm -f "$BASE/fix.pid"; exit 0' INT TERM + note "auto-rejoin on: Wi-Fi off/on ${REJOIN_EARLY}s before a short (<=$((MAX_LEASE / 60)) min) lease runs out" + while :; do + la=$(lease_age_min); lt=$(ipconfig getoption "$IF" lease_time 2>/dev/null) + if networksetup -getairportpower "$IF" | grep -q ': On'; then # never switch on Wi-Fi the user turned off + a=$(ipconfig getifaddr "$IF") + if [ -z "$a" ] || [[ $a == 169.254.* ]]; then miss=$((miss + 1)); else miss=0; fi + if { [ -n "$la" ] && [ -n "$lt" ] && [ "$lt" -le $MAX_LEASE ] && [ $((la * 60)) -ge $((lt - REJOIN_EARLY)) ]; } || [ $miss -ge 3 ]; then + note "rejoining (address age ${la:-none} min, lease ${lt:-?}s)" + touch "$BASE/rejoin.stamp" # lets the monitor label the blip as planned + off=1; networksetup -setairportpower "$IF" off; sleep 2; networksetup -setairportpower "$IF" on; off='' + sleep 20; miss=0 + fi + fi + sleep 10 + done +} + +report() { # one line per lost lease or Wi-Fi drop found in macOS's own log + echo "Wi-Fi/DHCP history for $IF, last ${1:-24h}, from macOS's own log" + /usr/bin/log show --last "${1:-24h}" --style compact --predicate \ + "process == \"configd\" AND subsystem == \"com.apple.IPConfiguration\" AND eventMessage CONTAINS \"$IF\"" 2>/dev/null | + awk -v IF="$IF" ' + function sec(t, a) { split(t, a, ":"); return a[1] * 3600 + a[2] * 60 + a[3] } + function dur(a, b, d) { if (a == "" || b == "") return "?"; d = sec(b) - sec(a); if (d < 0) d += 86400; return int(d / 60) "m" sprintf("%02d", d % 60) "s" } # ponytail: assumes < 24h apart + function v(x) { return x == "" ? "-" : x } + /^20[0-9][0-9]-/ { + t = substr($2, 1, 8); if (lost == "") day = $1 + if ($0 ~ "dhcp_bound\\(" IF "\\)") { + if (lost != "") { + n++ + printf "%s #%d address %s | renewal unanswered %s | address lost %s | Wi-Fi off/on %s | back %s => no address %s, lease age at loss %s%s\n", \ + day, n, v(lease), v(renew), lost, v(drop), t, dur(lost, t), dur(lease, lost), why + } + lease = t; renew = lost = drop = why = "" + } + else if ($0 ~ "DHCP " IF ": RENEW$" && renew == "") renew = t + else if ($0 ~ /server not responding/) lost = t + else if ($0 ~ IF " link INACTIVE") { if (drop == "") drop = t; if (lost == "") { lost = t; why = " (Wi-Fi went off while the address was still valid: not a lease failure)" } } + } + END { + if (lost != "") { # the log ends mid-outage: count it rather than report no drops + n++ + printf "%s #%d address %s | renewal unanswered %s | address lost %s | Wi-Fi off/on %s | no recovery logged yet%s\n", \ + day, n, v(lease), v(renew), lost, v(drop), why + } + if (n == 0) print "no drops found"; else printf "%d drops\n", n + }' +} + +shortcut() { # double-clickable Desktop app: shows whether the fix is on and turns it on or off + local app="$HOME/Desktop/Wi-Fi Fix.app" me + me="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" + osacompile -o "$app" </dev/null 2>&1 & sleep 3; cat "$(latest)/events.log" ;; + status) summary "$(latest)" ;; + stop) if kill "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then sleep 3; summary "$(latest)"; else echo "not running"; fi ;; + report) report "$2" ;; + fix-on) if alive "$BASE/fix.pid"; then echo "already on"; else nohup bash "$0" fixloop >/dev/null 2>&1 & sleep 2; tail -1 "$BASE/fix.log"; fi ;; + fix-off) if kill "$(cat "$BASE/fix.pid" 2>/dev/null)" 2>/dev/null; then echo "auto-rejoin off"; else echo "not running"; fi ;; + fix-status) if alive "$BASE/fix.pid"; then echo on; else echo off; fi ;; + fixloop) fixloop ;; + shortcut) shortcut ;; + *) sed -n '/^# Usage:/,/^# Author:/p' "$0" | sed '$d' ;; +esac diff --git a/Python/VENV.txt b/Python/VENV.txt index db4b046..fba5649 100644 --- a/Python/VENV.txt +++ b/Python/VENV.txt @@ -7,11 +7,11 @@ source .venv/bin/activate Install modules: python -m pip install -r requirements.txt -Run the script: -python book.py +Run a script: +python hibp.py Leave the venv: deactivate Shortcut without starting the venv: -./.venv/bin/python book.py +./.venv/bin/python hibp.py diff --git a/Python/book.py b/Python/book.py deleted file mode 100644 index 4396f3a..0000000 --- a/Python/book.py +++ /dev/null @@ -1,573 +0,0 @@ -#!/usr/bin/env python3 -""" -Meeting Room 3 Auto-Booker -Books Mon–Fri 08:00–17:00 for the next 4 weeks (platform max). - -Usage: - python book.py # book everything available - python book.py --dry-run # preview without submitting - python book.py --yes # skip per-batch confirmation -""" - -import argparse -import base64 -import hashlib -import json -import os -import random -import re -import secrets -import sys -import time -from datetime import date, timedelta -from pathlib import Path -from urllib.parse import parse_qs, urlsplit - -import requests - - -ENV_FILE = Path(__file__).with_name(".env") - -# ─── CONFIG ─────────────────────────────────────────────────────────────────── -EMAIL = os.environ.get("BOOKER_EMAIL", "") -PASSWORD = os.environ.get("BOOKER_PASSWORD", "") -BASE_URL = os.environ.get("BOOKER_BASE_URL", "") -OAUTH_AUTH_URL = os.environ.get("BOOKER_AUTH_URL", "").rstrip("/") -OAUTH_CLIENT_ID = os.environ.get("BOOKER_OAUTH_CLIENT_ID", "") -OAUTH_ACCESS_COOKIE = os.environ.get("BOOKER_OAUTH_ACCESS_COOKIE", "") -OAUTH_LOGIN_TOKEN_RE = re.compile(r'name="_token" value="([^"]+)"') - -AMENITY_ID = "66679a76a3f946d03c0c8ffc" -AMENITY_NAME = "Meeting Room 3" - -TARGET_SLOTS = [ - "08:00-09:00", "09:00-10:00", "10:00-11:00", - "11:00-12:00", "12:00-13:00", "13:00-14:00", - "14:00-15:00", "15:00-16:00", "16:00-17:00", -] - -# booking window is now read from the amenity's booking_available_days field - -# ─── RATE LIMITING ──────────────────────────────────────────────────────────── -DELAY_BETWEEN_REQUESTS = 1.5 # seconds between every API call -MIN_DELAY_BETWEEN_BOOKINGS = 16.0 # random pause after booking/save -MAX_DELAY_BETWEEN_BOOKINGS = 59.0 # random pause after booking/save -DELAY_ON_ERROR = 10.0 # back-off on any non-200 response -# ────────────────────────────────────────────────────────────────────────────── - -SESSION_HEADERS = { - "app-mb": "__@mbv4vbm@__", - "Content-Type": "application/json", - "Accept": "application/json, text/plain, */*", - "Accept-Language": "en-GB,en;q=0.9,en-US;q=0.8,de;q=0.7,de-DE;q=0.6", - "Accept-Encoding": "gzip, deflate, br, zstd", - "Cache-Control": "no-cache", - "Pragma": "no-cache", - "sec-ch-ua": '"Not:A-Brand";v="99", "Microsoft Edge";v="145", "Chromium";v="145"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"macOS"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-origin", - "User-Agent": ( - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0" - ), -} - - -def app_url_from_base(base_url: str) -> str: - parts = urlsplit(base_url) - if not parts.scheme or not parts.netloc: - return "" - return f"{parts.scheme}://{parts.netloc}" - - -def oauth_authorize_url(auth_url: str) -> str: - return f"{auth_url}/oauth/authorize" - - -def oauth_login_url(auth_url: str) -> str: - return f"{auth_url}/login" - - -def code_verifier() -> str: - return base64.urlsafe_b64encode(secrets.token_bytes(48)).decode().rstrip("=") - - -def code_challenge(verifier: str) -> str: - digest = hashlib.sha256(verifier.encode("utf-8")).digest() - return base64.urlsafe_b64encode(digest).decode().rstrip("=") - - -def get_cookie_value(session: requests.Session, name: str) -> str: - for cookie in session.cookies: - if cookie.name == name: - return cookie.value - return "" - - -def oauth_session_from(session: requests.Session) -> requests.Session: - auth_session = requests.Session() - for header in ( - "User-Agent", - "Accept-Language", - "Accept-Encoding", - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - ): - if header in session.headers: - auth_session.headers[header] = session.headers[header] - auth_session.headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - return auth_session - - -def extract_oauth_code(url: str, expected_state: str, *, required: bool) -> str: - query = parse_qs(urlsplit(url).query) - error = query.get("error", [""])[0] - if error: - description = query.get("error_description", [""])[0] - if description: - raise RuntimeError(f"OAuth authorize failed: {error} ({description})") - raise RuntimeError(f"OAuth authorize failed: {error}") - - code = query.get("code", [""])[0] - if not code: - if required: - raise RuntimeError( - f"OAuth login did not reach callback URL. Final URL: {url}" - ) - return "" - - returned_state = query.get("state", [""])[0] - if returned_state != expected_state: - raise RuntimeError("OAuth state mismatch during login") - return code - - -def load_env_file(env_file: Path) -> None: - if not env_file.is_file(): - return - - for raw_line in env_file.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[7:].strip() - if "=" not in line: - continue - - key, value = line.split("=", 1) - key = key.strip() - value = value.strip() - if not key: - continue - if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: - value = value[1:-1] - os.environ.setdefault(key, value) - - -def refresh_config_from_env() -> None: - global EMAIL, PASSWORD, BASE_URL, OAUTH_AUTH_URL, OAUTH_CLIENT_ID, OAUTH_ACCESS_COOKIE - EMAIL = os.environ.get("BOOKER_EMAIL", "") - PASSWORD = os.environ.get("BOOKER_PASSWORD", "") - BASE_URL = os.environ.get("BOOKER_BASE_URL", "") - OAUTH_AUTH_URL = os.environ.get("BOOKER_AUTH_URL", "").rstrip("/") - OAUTH_CLIENT_ID = os.environ.get("BOOKER_OAUTH_CLIENT_ID", "") - OAUTH_ACCESS_COOKIE = os.environ.get("BOOKER_OAUTH_ACCESS_COOKIE", "") - - -def pause(seconds: float, label: str = "") -> None: - jitter = seconds * random.uniform(0.7, 1.3) - if label: - print(f" ⏳ waiting {jitter:.1f}s {label}…", end=" ", flush=True) - time.sleep(jitter) - if label: - print("done") - - -def pause_range(min_seconds: float, max_seconds: float, label: str = "") -> None: - wait = random.uniform(min_seconds, max_seconds) - if label: - print(f" ⏳ waiting {wait:.1f}s {label}…", end=" ", flush=True) - time.sleep(wait) - if label: - print("done") - - -def login(session: requests.Session) -> None: - app_url = app_url_from_base(BASE_URL) - if not app_url: - raise RuntimeError("Invalid base URL") - if not OAUTH_AUTH_URL: - raise RuntimeError("Missing BOOKER_AUTH_URL") - if not OAUTH_CLIENT_ID: - raise RuntimeError("Missing BOOKER_OAUTH_CLIENT_ID") - if not OAUTH_ACCESS_COOKIE: - raise RuntimeError("Missing BOOKER_OAUTH_ACCESS_COOKIE") - - session.headers.pop("Authorization", None) - auth_session = oauth_session_from(session) - authorize_url = oauth_authorize_url(OAUTH_AUTH_URL) - login_url = oauth_login_url(OAUTH_AUTH_URL) - - verifier = code_verifier() - state = secrets.token_urlsafe(16) - redirect_uri = f"{app_url}/auth/callback" - - authorize_resp = auth_session.get( - authorize_url, - params={ - "client_id": OAUTH_CLIENT_ID, - "redirect_uri": redirect_uri, - "response_type": "code", - "scope": "", - "state": state, - "code_challenge": code_challenge(verifier), - "code_challenge_method": "S256", - }, - allow_redirects=True, - ) - authorize_resp.raise_for_status() - - code = extract_oauth_code(authorize_resp.url, state, required=False) - if not code: - login_token_match = OAUTH_LOGIN_TOKEN_RE.search(authorize_resp.text) - if not login_token_match: - raise RuntimeError( - f"Could not find OAuth login form token. Final URL: {authorize_resp.url}" - ) - - login_resp = auth_session.post( - login_url, - data={ - "_token": login_token_match.group(1), - "email": EMAIL, - "password": PASSWORD, - }, - headers={ - "Content-Type": "application/x-www-form-urlencoded", - "Origin": OAUTH_AUTH_URL, - "Referer": authorize_resp.url, - }, - allow_redirects=True, - ) - login_resp.raise_for_status() - code = extract_oauth_code(login_resp.url, state, required=True) - - token_resp = auth_session.post( - f"{app_url}/oauth/token", - json={ - "client_id": OAUTH_CLIENT_ID, - "grant_type": "authorization_code", - "redirect_uri": redirect_uri, - "code_verifier": verifier, - "code": code, - }, - headers={ - "Origin": app_url, - "Referer": redirect_uri, - }, - ) - token_resp.raise_for_status() - - for cookie in auth_session.cookies: - session.cookies.set_cookie(cookie) - - token = get_cookie_value(session, OAUTH_ACCESS_COOKIE) - if not token: - raise RuntimeError( - "OAuth token cookie missing after token exchange. " - f"Response was: {token_resp.text[:300]}" - ) - - session.headers["Authorization"] = f"Bearer {token}" - print("βœ… Logged in - OAuth session established") - pause(DELAY_BETWEEN_REQUESTS) - - -def api_post( - session: requests.Session, - path: str, - *, - payload: dict, - params: dict | None = None, -) -> requests.Response: - resp = session.post(f"{BASE_URL}{path}", params=params, json=payload) - if resp.status_code == 401: - print(" πŸ” Session expired, signing in again...") - login(session) - resp = session.post(f"{BASE_URL}{path}", params=params, json=payload) - return resp - - -def get_amenity(session: requests.Session) -> dict: - resp = api_post(session, "/resident/amenity/single", payload={"_id": AMENITY_ID}) - resp.raise_for_status() - amenity = resp.json() - print(f"βœ… Amenity loaded: {amenity.get('name')}") - pause(DELAY_BETWEEN_REQUESTS) - return amenity - - -def get_profile(session: requests.Session) -> dict: - resp = api_post( - session, - "/resident/profile/me", - payload={"enablePendo": False, "enableAvatar": False}, - ) - resp.raise_for_status() - profile = resp.json() - print(f"βœ… Profile loaded: {profile.get('first_name')} {profile.get('last_name')}") - pause(DELAY_BETWEEN_REQUESTS) - return profile - - -def get_enabled_dates(session: requests.Session, month: int, year: int) -> set: - resp = api_post( - session, - f"/resident/amenity/booking/{AMENITY_ID}", - params={"month": month, "year": year}, - payload={"id": AMENITY_ID, "m": month, "y": year}, - ) - resp.raise_for_status() - pause(DELAY_BETWEEN_REQUESTS) - return set(resp.json().get("enabled_dates", [])) - - -def get_slots(session: requests.Session, date_str: str, verbose: bool = False) -> dict: - resp = api_post( - session, - f"/resident/amenity/booking/{AMENITY_ID}/{date_str}", - payload={"id": AMENITY_ID, "date": date_str}, - ) - if resp.status_code != 200: - print(f" ⚠️ slot check returned {resp.status_code}, backing off {DELAY_ON_ERROR}s") - pause(DELAY_ON_ERROR) - return {} - pause(DELAY_BETWEEN_REQUESTS) - data = resp.json().get("data", []) - if verbose: - print(f" πŸ“‹ Raw slot data for {date_str}:") - for s in data: - icon = "🟒" if s.get("status") else "πŸ”΄" - print(f" {icon} {s['time']:14s} status={s.get('status')} available_slots={s.get('available_slots')}") - # status=True β†’ available/bookable; status=False β†’ already booked - return {s["time"]: s["status"] for s in data} - - -def book(session: requests.Session, amenity: dict, profile: dict, - date_str: str, slots: list, dry_run: bool) -> bool: - payload = { - **amenity, - "amenity_id": AMENITY_ID, - "date": date_str, - "time": slots, - "booker_first_name": profile.get("first_name", ""), - "booker_last_name": profile.get("last_name", ""), - "booker_email": profile.get("email", ""), - "booker_mobile": profile.get("mobile", ""), - "booker_phone": profile.get("phone", ""), - } - if dry_run: - print(f" πŸ” DRY RUN β†’ {date_str}: {slots}") - pause(0.2) - return True - resp = api_post(session, "/resident/amenity/booking/save", payload=payload) - if resp.status_code == 200: - try: - msg = resp.json().get("message", "OK") - except Exception: - msg = resp.text[:80] if resp.text.strip() else "OK (empty body)" - print(f" βœ… {msg}") - pause_range(MIN_DELAY_BETWEEN_BOOKINGS, MAX_DELAY_BETWEEN_BOOKINGS, "after booking") - return True - else: - print(f" ❌ {resp.status_code}: {resp.text[:200]}") - pause(DELAY_ON_ERROR, "back-off after error") - return False - - -def weekdays_in_range(start: date, end: date, reverse: bool = False): - days = [] - d = start - while d <= end: - if d.weekday() < 5: - days.append(d) - d += timedelta(days=1) - return reversed(days) if reverse else iter(days) - - -def confirm(prompt: str) -> bool: - try: - answer = input(f"{prompt} [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - print() - return False - return answer in ("y", "yes") - - -def main(): - parser = argparse.ArgumentParser(description="Meeting Room 3 auto-booker") - parser.add_argument("--dry-run", action="store_true", - help="Preview without actually booking") - parser.add_argument("--yes", "-y", action="store_true", - help="Skip confirmation prompts") - parser.add_argument("--email", "-u", default=None, - help="Login email (overrides BOOKER_EMAIL env var)") - parser.add_argument("--password", "-p", default=None, - help="Login password (overrides BOOKER_PASSWORD env var)") - parser.add_argument("--base-url", "-b", default=None, - help="API base URL (overrides BOOKER_BASE_URL env var)") - args = parser.parse_args() - - load_env_file(ENV_FILE) - refresh_config_from_env() - - # Allow CLI config to override env vars - global EMAIL, PASSWORD, BASE_URL - if args.email: - EMAIL = args.email - if args.password: - PASSWORD = args.password - if args.base_url: - BASE_URL = args.base_url - - required_config = { - "BOOKER_EMAIL": EMAIL, - "BOOKER_PASSWORD": PASSWORD, - "BOOKER_BASE_URL": BASE_URL, - "BOOKER_AUTH_URL": OAUTH_AUTH_URL, - "BOOKER_OAUTH_CLIENT_ID": OAUTH_CLIENT_ID, - "BOOKER_OAUTH_ACCESS_COOKIE": OAUTH_ACCESS_COOKIE, - } - missing_config = [name for name, value in required_config.items() if not value] - if missing_config: - print("❌ Missing required configuration:") - for name in missing_config: - print(f" {name}") - print(f" Set them in {ENV_FILE}") - print(" CLI overrides exist for BOOKER_EMAIL / BOOKER_PASSWORD / BOOKER_BASE_URL") - sys.exit(1) - - app_url = app_url_from_base(BASE_URL) - if not app_url: - print("❌ Invalid base URL") - sys.exit(1) - - session = requests.Session() - session.headers.update(SESSION_HEADERS) - session.headers["Origin"] = app_url - session.headers["Referer"] = f"{app_url}/resident/amenity" - login(session) - - amenity = get_amenity(session) - profile = get_profile(session) - - # Use the platform's own booking_available_days limit instead of a hardcoded window - today = date.today() - avail_days = int(amenity.get("booking_available_days", 30)) - max_end = today + timedelta(days=avail_days) - print(f" ℹ️ Platform allows bookings up to {avail_days} days ahead (until {max_end})") - - # Ask user how many days to scan - if not args.yes: - print(f"\nπŸ“† How far ahead do you want to scan for available slots?") - print(f" 1) Last 1 week (faster, ~{min(7, avail_days)}d)") - print(f" 2) Last 2 weeks (~{min(14, avail_days)}d)") - print(f" 3) Full {avail_days} days (slower, until {max_end})") - try: - choice = input(" Choice [1/2/3, default=3]: ").strip() or "3" - except (EOFError, KeyboardInterrupt): - choice = "3" - scan_days = {"1": 7, "2": 14}.get(choice, avail_days) - else: - scan_days = avail_days - - # Always end at the platform maximum; start is max_end - scan_days (but no earlier than today) - scan_start = max(today, max_end - timedelta(days=scan_days)) - end = max_end - print(f"\nπŸ“… Window: {scan_start} β†’ {end} ({(end - scan_start).days}d, newest first) | Mon–Fri | {TARGET_SLOTS[0]}–{TARGET_SLOTS[-1]}") - print(f"⏱ Delays: {DELAY_BETWEEN_REQUESTS}s between requests, " - f"{MIN_DELAY_BETWEEN_BOOKINGS:.0f}-{MAX_DELAY_BETWEEN_BOOKINGS:.0f}s random after each booking\n") - - # ── Phase 1: discovery (read-only) ──────────────────────────────────────── - print("πŸ” Phase 1 β€” checking availability (no bookings yet)…\n") - - enabled_cache: dict[tuple, set] = {} - plan: list[tuple[date, list]] = [] # (day, slots_to_book) - - for day in weekdays_in_range(scan_start, end, reverse=True): - key = (day.month, day.year) - if key not in enabled_cache: - print(f" πŸ“† Fetching enabled dates for {day.strftime('%B %Y')}…") - enabled_cache[key] = get_enabled_dates(session, day.month, day.year) - - date_str = day.strftime("%Y-%m-%d") - label = f"{date_str} ({day.strftime('%a')})" - - if date_str not in enabled_cache[key]: - print(f" ⏭ {label} β€” date disabled by platform") - continue - - slot_avail = get_slots(session, date_str, verbose=True) - slots_to_book = [s for s in TARGET_SLOTS if slot_avail.get(s, False) is True] - taken = [s for s in TARGET_SLOTS if not slot_avail.get(s, False)] - - if not slots_to_book: - print(f" ⏭ {label} β€” fully booked already") - continue - - if taken: - print(f" ⚠️ {label} β€” {len(taken)} slot(s) taken, {len(slots_to_book)} free") - else: - print(f" βœ” {label} β€” {len(slots_to_book)} slots free") - - plan.append((day, slots_to_book)) - - # ── Phase 1 summary + gate ───────────────────────────────────────────── - print(f"\n{'─'*55}") - print(f"PLAN: {len(plan)} day(s) to book, " - f"up to {len(plan) * len(TARGET_SLOTS)} slot(s) total") - for day, slots in plan: - print(f" {day.strftime('%Y-%m-%d %a')} β†’ {slots[0]} – {slots[-1]} ({len(slots)} slots)") - print(f"{'─'*55}\n") - - if not plan: - print("Nothing to book. Exiting.") - return - - if args.dry_run: - print("DRY RUN β€” no bookings will be made.") - return - - if not args.yes: - if not confirm(f"⚑ Proceed and book all {len(plan)} days?"): - print("Aborted.") - return - - # ── Phase 2: booking ────────────────────────────────────────────────────── - print( - f"\nπŸš€ Phase 2 β€” booking " - f"({MIN_DELAY_BETWEEN_BOOKINGS:.0f}-{MAX_DELAY_BETWEEN_BOOKINGS:.0f}s random cooldown between saves)…\n" - ) - booked, failed = 0, 0 - - for i, (day, slots) in enumerate(plan, 1): - date_str = day.strftime("%Y-%m-%d") - print(f" [{i}/{len(plan)}] {date_str} ({day.strftime('%a')}) β€” {len(slots)} slots") - ok = book(session, amenity, profile, date_str, slots, dry_run=False) - if ok: - booked += 1 - else: - failed += 1 - - print(f"\n{'─'*55}") - print(f"DONE: {booked} booked, {failed} failed") - - -if __name__ == "__main__": - main() diff --git a/Python/requirements.txt b/Python/requirements.txt index f522632..4ba91c4 100644 --- a/Python/requirements.txt +++ b/Python/requirements.txt @@ -1,3 +1,2 @@ requests>=2.32,<3 -flask python-dotenv