From 589d2802c26e9afe0ee1a1525dd81938149c6546 Mon Sep 17 00:00:00 2001 From: Andrew Pynch Date: Sat, 25 Apr 2026 21:52:19 -0500 Subject: [PATCH] v1 - whoop w / foreground app. WIP bg app (next week, needs more testing...) --- app-store/whoop/.env.example | 4 + app-store/whoop/.gitignore | 1 + app-store/whoop/config.py | 55 ++ app-store/whoop/icon.png | Bin 0 -> 43181 bytes app-store/whoop/prompts/whoop_cli_prompts.txt | 10 + .../whoop/scripts/bootstrap_whoop_oauth.py | 421 +++++++++++++++ app-store/whoop/scripts/run_whoop_prompts.py | 485 ++++++++++++++++++ app-store/whoop/tests/conftest.py | 7 + .../whoop/tests/test_whoop_app_shells.py | 119 +++++ app-store/whoop/truffile.yaml | 91 ++++ app-store/whoop/whoop_auth.py | 164 ++++++ app-store/whoop/whoop_background.py | 100 ++++ app-store/whoop/whoop_bg_worker.py | 457 +++++++++++++++++ app-store/whoop/whoop_client.py | 348 +++++++++++++ app-store/whoop/whoop_foreground.py | 386 ++++++++++++++ 15 files changed, 2648 insertions(+) create mode 100644 app-store/whoop/.env.example create mode 100644 app-store/whoop/.gitignore create mode 100644 app-store/whoop/config.py create mode 100644 app-store/whoop/icon.png create mode 100644 app-store/whoop/prompts/whoop_cli_prompts.txt create mode 100644 app-store/whoop/scripts/bootstrap_whoop_oauth.py create mode 100644 app-store/whoop/scripts/run_whoop_prompts.py create mode 100644 app-store/whoop/tests/conftest.py create mode 100644 app-store/whoop/tests/test_whoop_app_shells.py create mode 100644 app-store/whoop/truffile.yaml create mode 100644 app-store/whoop/whoop_auth.py create mode 100644 app-store/whoop/whoop_background.py create mode 100644 app-store/whoop/whoop_bg_worker.py create mode 100644 app-store/whoop/whoop_client.py create mode 100644 app-store/whoop/whoop_foreground.py diff --git a/app-store/whoop/.env.example b/app-store/whoop/.env.example new file mode 100644 index 0000000..8e664f4 --- /dev/null +++ b/app-store/whoop/.env.example @@ -0,0 +1,4 @@ +WHOOP_CLIENT_ID= +WHOOP_CLIENT_SECRET= +# for bootstrap_whoop_oauth.py +WHOOP_REDIRECT_URI=http://127.0.0.1:8765/callback diff --git a/app-store/whoop/.gitignore b/app-store/whoop/.gitignore new file mode 100644 index 0000000..62dd1dd --- /dev/null +++ b/app-store/whoop/.gitignore @@ -0,0 +1 @@ +artifacts/* diff --git a/app-store/whoop/config.py b/app-store/whoop/config.py new file mode 100644 index 0000000..d664089 --- /dev/null +++ b/app-store/whoop/config.py @@ -0,0 +1,55 @@ +"""Configuration helpers for the WHOOP Truffle app.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +DEFAULT_WHOOP_API_BASE = "https://api.prod.whoop.com/developer" +DEFAULT_WHOOP_TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token" +DEFAULT_TOKEN_STORE_PATH = Path.home() / ".whoop-truffle" / "oauth.json" + + +def _parse_optional_float(raw: str | None) -> float | None: + value = (raw or "").strip() + if not value: + return None + try: + return float(value) + except ValueError: + return None + + +@dataclass(frozen=True, slots=True) +class WhoopConfig: + client_id: str = "" + client_secret: str = "" + redirect_uri: str = "" + access_token: str = "" + refresh_token: str = "" + api_base: str = DEFAULT_WHOOP_API_BASE + token_url: str = DEFAULT_WHOOP_TOKEN_URL + token_store_path: Path = DEFAULT_TOKEN_STORE_PATH + access_token_expires_at: float | None = None + token_scope: str = "" + token_type: str = "bearer" + + @classmethod + def from_env(cls) -> WhoopConfig: + token_store_raw = os.getenv("WHOOP_TOKEN_STORE_PATH", "").strip() + token_store_path = Path(token_store_raw) if token_store_raw else DEFAULT_TOKEN_STORE_PATH + return cls( + client_id=os.getenv("WHOOP_CLIENT_ID", "").strip(), + client_secret=os.getenv("WHOOP_CLIENT_SECRET", "").strip(), + redirect_uri=os.getenv("WHOOP_REDIRECT_URI", "").strip(), + access_token=os.getenv("WHOOP_ACCESS_TOKEN", "").strip(), + refresh_token=os.getenv("WHOOP_REFRESH_TOKEN", "").strip(), + api_base=os.getenv("WHOOP_API_BASE", DEFAULT_WHOOP_API_BASE).strip() or DEFAULT_WHOOP_API_BASE, + token_url=os.getenv("WHOOP_TOKEN_URL", DEFAULT_WHOOP_TOKEN_URL).strip() or DEFAULT_WHOOP_TOKEN_URL, + token_store_path=token_store_path, + access_token_expires_at=_parse_optional_float(os.getenv("WHOOP_ACCESS_TOKEN_EXPIRES_AT")), + token_scope=os.getenv("WHOOP_TOKEN_SCOPE", "").strip(), + token_type=os.getenv("WHOOP_TOKEN_TYPE", "bearer").strip() or "bearer", + ) diff --git a/app-store/whoop/icon.png b/app-store/whoop/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..37c89a4fb161af485dd33d61edb0c3cf39c2379e GIT binary patch literal 43181 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�O|F*YGuemCB#>#2aQ6v9m3iHHjjZ|L3g;okk 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 literal 0 HcmV?d00001 diff --git a/app-store/whoop/prompts/whoop_cli_prompts.txt b/app-store/whoop/prompts/whoop_cli_prompts.txt new file mode 100644 index 0000000..b19ccbf --- /dev/null +++ b/app-store/whoop/prompts/whoop_cli_prompts.txt @@ -0,0 +1,10 @@ +Check my WHOOP connection status. +If auth fails, explain exactly what failed and what needs to be reauthorized. +--- +Pull my latest WHOOP summary and give me recovery, sleep quality, day strain, and recent workouts. +--- +Show my last 7 recovery records and summarize the trend in recovery score, HRV, and resting heart rate. +--- +Show my last 7 cycles and tell me whether day strain is rising, falling, or flat. +--- +Pull my WHOOP sleep records from the last week and tell me which night had the best and worst sleep performance. diff --git a/app-store/whoop/scripts/bootstrap_whoop_oauth.py b/app-store/whoop/scripts/bootstrap_whoop_oauth.py new file mode 100644 index 0000000..fc9a355 --- /dev/null +++ b/app-store/whoop/scripts/bootstrap_whoop_oauth.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""Bootstrap WHOOP OAuth locally and print Truffle deploy prompt values.""" + +from __future__ import annotations + +import json +import os +import queue +import secrets +import subprocess +import threading +import time +import urllib.parse +import webbrowser +from collections.abc import Mapping +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +ENV_FILE = SCRIPT_DIR / ".env" +DEFAULT_REDIRECT_URI = "http://127.0.0.1:8765/callback" +AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth" +TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token" +PROFILE_URL = "https://api.prod.whoop.com/developer/v2/user/profile/basic" +SCOPES = ( + "offline", + "read:profile", + "read:body_measurement", + "read:cycles", + "read:recovery", + "read:sleep", + "read:workout", +) +CALLBACK_TIMEOUT_SECONDS = 180 + + +@dataclass(frozen=True, slots=True) +class BootstrapConfig: + client_id: str + client_secret: str + redirect_uri: str = DEFAULT_REDIRECT_URI + + +@dataclass(frozen=True, slots=True) +class OAuthCallback: + code: str + state: str + error: str = "" + error_description: str = "" + + +def _decode_quoted_env_value(value: str, quote: str) -> str: + chars: list[str] = [] + escaped = False + for char in value[1:]: + if escaped: + if quote == '"' and char == "n": + chars.append("\n") + elif quote == '"' and char == "r": + chars.append("\r") + elif quote == '"' and char == "t": + chars.append("\t") + else: + chars.append(char) + escaped = False + continue + if char == "\\": + escaped = True + continue + if char == quote: + return "".join(chars) + chars.append(char) + return "".join(chars) + + +def _strip_unquoted_env_comment(value: str) -> str: + for index, char in enumerate(value): + if char == "#" and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + +def parse_env_file(path: Path) -> dict[str, str]: + values: dict[str, str] = {} + if not path.exists(): + return values + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export ") :].lstrip() + if "=" not in line: + continue + + key, raw_value = line.split("=", 1) + key = key.strip() + if not key: + continue + + value = raw_value.strip() + if value.startswith('"'): + values[key] = _decode_quoted_env_value(value, '"') + elif value.startswith("'"): + values[key] = _decode_quoted_env_value(value, "'") + else: + values[key] = _strip_unquoted_env_comment(value) + return values + + +def load_config(*, env: Mapping[str, str] = os.environ, env_file: Path = ENV_FILE) -> BootstrapConfig: + file_values = parse_env_file(env_file) + + def get_value(name: str, default: str = "") -> str: + value = str(env.get(name, "") or "").strip() + if value: + return value + return str(file_values.get(name, default) or "").strip() + + config = BootstrapConfig( + client_id=get_value("WHOOP_CLIENT_ID"), + client_secret=get_value("WHOOP_CLIENT_SECRET"), + redirect_uri=get_value("WHOOP_REDIRECT_URI", DEFAULT_REDIRECT_URI), + ) + + missing = [ + name + for name, value in ( + ("WHOOP_CLIENT_ID", config.client_id), + ("WHOOP_CLIENT_SECRET", config.client_secret), + ) + if not value + ] + if missing: + raise RuntimeError( + "Missing required WHOOP OAuth setting(s): " + f"{', '.join(missing)}. Create {env_file} from .env.example or export them in your shell." + ) + + return config + + +def generate_state() -> str: + return secrets.token_hex(4) + + +def build_authorization_url(*, state: str, config: BootstrapConfig) -> str: + query = urllib.parse.urlencode( + { + "response_type": "code", + "client_id": config.client_id, + "redirect_uri": config.redirect_uri, + "scope": " ".join(SCOPES), + "state": state, + } + ) + return f"{AUTH_URL}?{query}" + + +def exchange_authorization_code( + code: str, + *, + config: BootstrapConfig, + curl_runner: Any = subprocess.run, + now: float | None = None, +) -> dict[str, Any]: + raw = _curl_form_json( + TOKEN_URL, + { + "grant_type": "authorization_code", + "code": code, + "client_id": config.client_id, + "client_secret": config.client_secret, + "redirect_uri": config.redirect_uri, + }, + error_prefix="WHOOP token exchange failed", + curl_runner=curl_runner, + ) + + data = json.loads(raw) + if not isinstance(data, dict): + raise RuntimeError("WHOOP token exchange returned a non-object payload") + + access_token = str(data.get("access_token", "") or "").strip() + refresh_token = str(data.get("refresh_token", "") or "").strip() + if not access_token or not refresh_token: + raise RuntimeError("WHOOP token exchange did not return both access and refresh tokens") + + expires_in = int(data.get("expires_in", 0) or 0) + data["expires_at"] = int((now if now is not None else time.time()) + expires_in) + data["scope"] = str(data.get("scope", "") or "").strip() + data["token_type"] = str(data.get("token_type", "bearer") or "bearer").strip() or "bearer" + return data + + +def fetch_profile( + access_token: str, + *, + curl_runner: Any = subprocess.run, +) -> dict[str, Any]: + raw = _curl_json( + [ + "curl", + "-sS", + "--fail-with-body", + "--max-time", + "30", + "-H", + "Accept: application/json", + "-H", + f"Authorization: Bearer {access_token}", + PROFILE_URL, + ], + error_prefix="WHOOP profile smoke test failed", + curl_runner=curl_runner, + ) + + payload = json.loads(raw) + if not isinstance(payload, dict): + raise RuntimeError("WHOOP profile smoke test returned a non-object payload") + return payload + + +def _curl_form_json( + url: str, + form: dict[str, str], + *, + error_prefix: str, + curl_runner: Any = subprocess.run, +) -> str: + command = [ + "curl", + "-sS", + "--fail-with-body", + "--max-time", + "30", + "-X", + "POST", + "-H", + "Accept: application/json", + ] + for key, value in form.items(): + command.extend(["--data-urlencode", f"{key}={value}"]) + command.append(url) + return _curl_json(command, error_prefix=error_prefix, curl_runner=curl_runner) + + +def _curl_json( + command: list[str], + *, + error_prefix: str, + curl_runner: Any = subprocess.run, +) -> str: + try: + result = curl_runner(command, capture_output=True, text=True, timeout=35, check=False) + except FileNotFoundError as exc: + raise RuntimeError("curl is required to run the WHOOP OAuth bootstrap script") from exc + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"{error_prefix}: curl timed out") from exc + + stdout = str(getattr(result, "stdout", "") or "") + stderr = str(getattr(result, "stderr", "") or "") + returncode = int(getattr(result, "returncode", 1) or 0) + if returncode != 0: + detail = (stdout or stderr).strip() + suffix = f": {detail[:300]}" if detail else "" + raise RuntimeError(f"{error_prefix}: curl exited {returncode}{suffix}") + return stdout + + +def prompt_values(token_payload: dict[str, Any], *, config: BootstrapConfig) -> list[tuple[str, str]]: + return [ + ("WHOOP Client ID", config.client_id), + ("WHOOP Client Secret", config.client_secret), + ("WHOOP Redirect URI", config.redirect_uri), + ("WHOOP Access Token", str(token_payload["access_token"])), + ("WHOOP Refresh Token", str(token_payload["refresh_token"])), + ("WHOOP Access Token Expires At", str(token_payload["expires_at"])), + ("WHOOP Token Scope", str(token_payload.get("scope", "") or "")), + ("WHOOP Token Type", str(token_payload.get("token_type", "bearer") or "bearer")), + ] + + +def render_prompt_values(token_payload: dict[str, Any], *, config: BootstrapConfig) -> str: + lines = ["Paste these values into the matching `truffile deploy` prompts:"] + for label, value in prompt_values(token_payload, config=config): + lines.append(f"{label}:") + lines.append(value) + return "\n".join(lines) + + +class OAuthCallbackServer: + def __init__(self, *, redirect_uri: str) -> None: + parsed = urllib.parse.urlparse(redirect_uri) + self._expected_path = parsed.path or "/" + self._events: queue.Queue[OAuthCallback] = queue.Queue(maxsize=1) + handler_cls = self._build_handler() + host = parsed.hostname or "127.0.0.1" + port = parsed.port or 80 + try: + self._server = ThreadingHTTPServer((host, port), handler_cls) + except OSError as exc: + raise RuntimeError( + f"Could not bind the local WHOOP callback server on {host}:{port}. " + "Make sure the redirect URI is free and another process is not already using that port." + ) from exc + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + def start(self) -> None: + self._thread.start() + + def wait_for_callback(self, *, timeout: float) -> OAuthCallback: + try: + return self._events.get(timeout=timeout) + except queue.Empty as exc: + raise TimeoutError(f"Timed out waiting for WHOOP OAuth redirect after {int(timeout)} seconds") from exc + + def close(self) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=2) + + def _build_handler(self) -> type[BaseHTTPRequestHandler]: + outer = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + if parsed.path != outer._expected_path: + self.send_error(404, "Unexpected callback path") + return + + params = urllib.parse.parse_qs(parsed.query) + callback = OAuthCallback( + code=str(params.get("code", [""])[0] or "").strip(), + state=str(params.get("state", [""])[0] or "").strip(), + error=str(params.get("error", [""])[0] or "").strip(), + error_description=str(params.get("error_description", [""])[0] or "").strip(), + ) + if outer._events.empty(): + outer._events.put(callback) + + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + if callback.error: + message = "WHOOP authorization failed. You can close this window." + else: + message = "WHOOP authorization received. You can close this window and return to the terminal." + self.wfile.write(message.encode("utf-8")) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A003 + return None + + return Handler + + +def authorize_and_print_env(*, open_browser: bool = True) -> int: + config = load_config() + state = generate_state() + auth_url = build_authorization_url(state=state, config=config) + server = OAuthCallbackServer(redirect_uri=config.redirect_uri) + server.start() + try: + print("Before continuing, make sure this redirect URI is saved in the WHOOP dashboard:", flush=True) + print(f" {config.redirect_uri}", flush=True) + print("Click Update App after adding it; an unsaved redirect will be rejected.", flush=True) + print(flush=True) + print( + "The authorization URL includes the OAuth request scope 'offline' so WHOOP returns a refresh token. " + "It may not appear as a dashboard checkbox.", + flush=True, + ) + print(flush=True) + print("WHOOP authorization URL:", flush=True) + print(auth_url, flush=True) + print(flush=True) + + if open_browser: + try: + webbrowser.open(auth_url) + except Exception: + pass + + callback = server.wait_for_callback(timeout=CALLBACK_TIMEOUT_SECONDS) + if callback.error: + description = f": {callback.error_description}" if callback.error_description else "" + raise RuntimeError(f"WHOOP authorization failed with error '{callback.error}'{description}") + if not callback.code: + raise RuntimeError("WHOOP redirect did not include an authorization code") + if callback.state != state: + raise RuntimeError("WHOOP redirect state did not match the request state") + + token_payload = exchange_authorization_code(callback.code, config=config) + profile = fetch_profile(str(token_payload["access_token"])) + print( + f"WHOOP OAuth OK for user_id={profile.get('user_id')} email={profile.get('email')}", + flush=True, + ) + print(flush=True) + print("Run `truffile deploy ./app-store/whoop` and paste the generated values when prompted.", flush=True) + print("Do not commit these token values.", flush=True) + print(flush=True) + print(render_prompt_values(token_payload, config=config), flush=True) + return 0 + finally: + server.close() + + +def main() -> int: + if "BROWSER" in os.environ and os.environ["BROWSER"].strip().lower() == "none": + return authorize_and_print_env(open_browser=False) + return authorize_and_print_env(open_browser=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app-store/whoop/scripts/run_whoop_prompts.py b/app-store/whoop/scripts/run_whoop_prompts.py new file mode 100644 index 0000000..1e1a71e --- /dev/null +++ b/app-store/whoop/scripts/run_whoop_prompts.py @@ -0,0 +1,485 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +APP_DIR = Path(__file__).resolve().parents[1] +DEFAULT_PROMPTS = APP_DIR / "prompts" / "whoop_cli_prompts.txt" +DEFAULT_OUT = APP_DIR / "artifacts" / "whoop-cli" + + +@dataclass +class CommandResult: + command: list[str] + returncode: int + stdout: str + stderr: str + error: str | None = None + + +@dataclass +class PromptRun: + result: CommandResult + payload: dict[str, Any] | None + validation_error: str | None + warnings: list[str] + settle_attempts: list[dict[str, Any]] + + +def _slug(value: str) -> str: + return value.lower().replace(" ", "-") + + +def _resolve_app_ref(ref: str, apps: list[dict[str, Any]]) -> dict[str, Any] | None: + ref_s = ref.strip() + ref_l = ref_s.lower() + for app in apps: + if app.get("uuid") == ref_s: + return app + for app in apps: + if str(app.get("name", "")).lower() == ref_l: + return app + for app in apps: + if _slug(str(app.get("name", ""))) == ref_l: + return app + for app in apps: + if ref_l in str(app.get("name", "")).lower(): + return app + return None + + +def _parse_prompt_file(path: Path) -> list[str]: + text = path.read_text(encoding="utf-8") + prompts: list[str] = [] + current: list[str] = [] + for line in text.splitlines(): + if line.strip() == "---": + block = "\n".join(current).strip() + if block: + prompts.append(block) + current = [] + else: + current.append(line) + block = "\n".join(current).strip() + if block: + prompts.append(block) + return prompts + + +def _run(command: list[str], *, timeout: float | None) -> CommandResult: + try: + completed = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + return CommandResult( + command=command, + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + except FileNotFoundError as exc: + return CommandResult( + command=command, + returncode=127, + stdout="", + stderr="", + error=f"command not found: {command[0]} ({exc})", + ) + except subprocess.TimeoutExpired as exc: + return CommandResult( + command=command, + returncode=124, + stdout=exc.stdout or "", + stderr=exc.stderr or "", + error=f"command timed out after {timeout} seconds", + ) + + +def _load_json(stdout: str) -> tuple[dict[str, Any] | None, str | None]: + try: + payload = json.loads(stdout) + except json.JSONDecodeError as exc: + return None, str(exc) + if not isinstance(payload, dict): + return None, "stdout JSON was not an object" + return payload, None + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _content_preview(content: Any, *, limit: int = 180) -> str: + text = " ".join(str(content or "").split()) + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "..." + + +def _make_run_dir(base: Path) -> Path: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + candidate = base / stamp + if not candidate.exists(): + candidate.mkdir(parents=True) + return candidate + for index in range(2, 1000): + candidate = base / f"{stamp}-{index}" + if not candidate.exists(): + candidate.mkdir(parents=True) + return candidate + raise RuntimeError(f"could not create unique run directory under {base}") + + +def _list_apps(truffile: str, *, timeout: float | None) -> tuple[list[dict[str, Any]] | None, str | None]: + result = _run([truffile, "chat", "--quiet", "--json", "--list-apps"], timeout=timeout) + if result.returncode != 0: + detail = result.error or result.stderr.strip() or result.stdout.strip() or "unknown error" + return None, f"`truffile chat --list-apps` failed: {detail}" + payload, error = _load_json(result.stdout) + if error: + return None, f"`truffile chat --list-apps` returned invalid JSON: {error}" + apps = payload.get("apps") if payload else None + if not isinstance(apps, list): + return None, "`truffile chat --list-apps` JSON did not include an apps list" + return [app for app in apps if isinstance(app, dict)], None + + +def _peek_task( + *, + truffile: str, + task_id: str, + timeout: float | None, +) -> tuple[CommandResult, dict[str, Any] | None, str | None]: + command = [truffile, "chat", "--quiet", "--json", "--task-id", task_id] + result = _run(command, timeout=timeout) + if result.returncode != 0: + detail = result.error or result.stderr.strip() or result.stdout.strip() or "unknown error" + return result, None, f"settle command failed: {detail}" + payload, error = _load_json(result.stdout) + if error: + return result, None, f"settle command returned invalid JSON: {error}" + return result, payload, None + + +def _record_settle_attempt( + *, + attempt: int, + result: CommandResult, + payload: dict[str, Any] | None, + error: str | None, +) -> dict[str, Any]: + return { + "attempt": attempt, + "command": result.command, + "exit_code": result.returncode, + "error": error, + "task_id": payload.get("task_id") if payload else None, + "pending_user_response": payload.get("pending_user_response") if payload else None, + "content_preview": _content_preview(payload.get("content") if payload else ""), + "stdout": result.stdout if error else None, + "stderr": result.stderr if error else None, + "command_error": result.error, + } + + +def _validate_payload( + payload: dict[str, Any], + *, + fail_on_pending: bool, +) -> tuple[str | None, list[str]]: + warnings: list[str] = [] + content = str(payload.get("content") or "").strip() + if not content: + return "response content was empty", warnings + if payload.get("pending_user_response") is True: + message = "response is waiting for a follow-up user message" + if fail_on_pending: + return message, warnings + warnings.append(message) + return None, warnings + + +def _run_prompt( + *, + truffile: str, + app_ref: str, + prompt: str, + timeout: float | None, + settle_checks: int, + settle_delay: float, + fail_on_pending: bool, +) -> PromptRun: + prompt_path: Path | None = None + try: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".txt", delete=False) as handle: + handle.write(prompt) + handle.write("\n") + prompt_path = Path(handle.name) + command = [ + truffile, + "chat", + "--quiet", + "--json", + "--app", + app_ref, + "--prompt-file", + str(prompt_path), + ] + result = _run(command, timeout=timeout) + finally: + if prompt_path is not None: + try: + prompt_path.unlink() + except OSError: + pass + + if result.returncode != 0: + detail = result.error or result.stderr.strip() or result.stdout.strip() or "unknown error" + return PromptRun(result, None, f"command failed: {detail}", [], []) + payload, error = _load_json(result.stdout) + if error: + return PromptRun(result, None, f"invalid JSON response: {error}", [], []) + + settle_attempts: list[dict[str, Any]] = [] + warnings: list[str] = [] + task_id = str(payload.get("task_id") or "") + for attempt in range(1, max(0, settle_checks) + 1): + content = str(payload.get("content") or "").strip() + pending = payload.get("pending_user_response") is True + if content and not pending: + break + if not task_id: + break + if settle_delay > 0: + time.sleep(settle_delay) + settle_result, settle_payload, settle_error = _peek_task( + truffile=truffile, + task_id=task_id, + timeout=timeout, + ) + settle_attempts.append( + _record_settle_attempt( + attempt=attempt, + result=settle_result, + payload=settle_payload, + error=settle_error, + ) + ) + if settle_error: + warnings.append(f"settle check {attempt} failed: {settle_error}") + break + if settle_payload is not None: + payload = settle_payload + + validation_error, validation_warnings = _validate_payload(payload, fail_on_pending=fail_on_pending) + warnings.extend(validation_warnings) + return PromptRun(result, payload, validation_error, warnings, settle_attempts) + + +def _failure_artifact( + *, + prompt_index: int, + prompt: str, + result: CommandResult, + validation_error: str, + parsed_payload: dict[str, Any] | None, + warnings: list[str], + settle_attempts: list[dict[str, Any]], +) -> dict[str, Any]: + return { + "status": "error", + "prompt_index": prompt_index, + "prompt": prompt, + "validation_error": validation_error, + "warnings": warnings, + "parsed_payload": parsed_payload, + "settle_attempts": settle_attempts, + "command": result.command, + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + "command_error": result.error, + } + + +def _summary_row( + *, + prompt_index: int, + prompt: str, + result: CommandResult, + payload: dict[str, Any] | None, + validation_error: str | None, + warnings: list[str], + settle_attempts: list[dict[str, Any]], + artifact: Path, +) -> dict[str, Any]: + return { + "prompt_index": prompt_index, + "exit_code": result.returncode, + "ok": validation_error is None, + "task_id": payload.get("task_id") if payload else None, + "attached_apps": payload.get("attached_apps") if payload else None, + "tool_calls": payload.get("tool_calls") if payload else None, + "pending_user_response": payload.get("pending_user_response") if payload else None, + "content_preview": _content_preview(payload.get("content") if payload else ""), + "prompt_preview": _content_preview(prompt), + "artifact": str(artifact), + "error": validation_error, + "warnings": warnings, + "settle_attempts": len(settle_attempts), + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run prompt-file tests against a deployed WHOOP Truffle app.", + ) + parser.add_argument( + "--prompts", + type=Path, + default=DEFAULT_PROMPTS, + help=f"prompt file to run; blocks are separated by lines containing only --- (default: {DEFAULT_PROMPTS})", + ) + parser.add_argument("--app", default="whoop", help="Truffle app name, slug, or uuid to attach") + parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help=f"artifact root (default: {DEFAULT_OUT})") + parser.add_argument("--truffile", default="truffile", help="truffile executable path (default: truffile)") + parser.add_argument( + "--timeout", + type=float, + default=None, + help="per truffile command timeout in seconds (default: no subprocess timeout)", + ) + parser.add_argument( + "--settle-checks", + type=int, + default=1, + help="number of --task-id polls after a pending or empty response before starting the next prompt (default: 1)", + ) + parser.add_argument( + "--settle-delay", + type=float, + default=0.5, + help="seconds to wait before each settle poll (default: 0.5)", + ) + parser.add_argument( + "--fail-on-pending", + action="store_true", + help="treat pending_user_response=true as a failure even when response content is present", + ) + parser.add_argument("--fail-fast", action="store_true", help="stop after the first failed prompt") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + prompt_path = args.prompts.expanduser() + if not prompt_path.is_file(): + print(f"prompt file not found: {prompt_path}", file=sys.stderr) + return 2 + + try: + prompts = _parse_prompt_file(prompt_path) + except OSError as exc: + print(f"could not read prompt file: {exc}", file=sys.stderr) + return 2 + if not prompts: + print(f"prompt file did not contain any prompts: {prompt_path}", file=sys.stderr) + return 2 + + apps, app_error = _list_apps(args.truffile, timeout=args.timeout) + if app_error: + print(app_error, file=sys.stderr) + return 1 + matched_app = _resolve_app_ref(args.app, apps or []) + if matched_app is None: + app_names = ", ".join(str(app.get("name") or app.get("uuid") or "") for app in apps or []) + print(f"could not find app matching {args.app!r}. Installed apps: {app_names}", file=sys.stderr) + return 1 + + run_dir = _make_run_dir(args.out.expanduser()) + summary_path = run_dir / "summary.jsonl" + app_name = str(matched_app.get("name") or args.app) + app_uuid = str(matched_app.get("uuid") or "") + print(f"WHOOP app: {app_name}" + (f" ({app_uuid})" if app_uuid else "")) + print(f"Prompts: {len(prompts)}") + print(f"Artifacts: {run_dir}") + + failures = 0 + with summary_path.open("w", encoding="utf-8") as summary: + for index, prompt in enumerate(prompts, start=1): + print(f"[{index}/{len(prompts)}] running: {_content_preview(prompt, limit=90)}") + prompt_run = _run_prompt( + truffile=args.truffile, + app_ref=args.app, + prompt=prompt, + timeout=args.timeout, + settle_checks=args.settle_checks, + settle_delay=args.settle_delay, + fail_on_pending=args.fail_on_pending, + ) + result = prompt_run.result + payload = prompt_run.payload + validation_error = prompt_run.validation_error + artifact_path = run_dir / f"{index:03d}.json" + if validation_error is None and payload is not None: + _write_json(artifact_path, payload) + else: + failures += 1 + _write_json( + artifact_path, + _failure_artifact( + prompt_index=index, + prompt=prompt, + result=result, + validation_error=validation_error or "unknown validation error", + parsed_payload=payload, + warnings=prompt_run.warnings, + settle_attempts=prompt_run.settle_attempts, + ), + ) + + row = _summary_row( + prompt_index=index, + prompt=prompt, + result=result, + payload=payload, + validation_error=validation_error, + warnings=prompt_run.warnings, + settle_attempts=prompt_run.settle_attempts, + artifact=artifact_path, + ) + summary.write(json.dumps(row, ensure_ascii=False) + "\n") + summary.flush() + if validation_error is None: + print(f"[{index}/{len(prompts)}] ok: {_content_preview(payload.get('content'), limit=90)}") + for warning in prompt_run.warnings: + print(f"[{index}/{len(prompts)}] warning: {warning}", file=sys.stderr) + else: + print(f"[{index}/{len(prompts)}] failed: {validation_error}", file=sys.stderr) + if args.fail_fast: + break + + print(f"Summary: {summary_path}") + if failures: + print(f"Failed prompts: {failures}", file=sys.stderr) + return 1 + print("All prompts completed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app-store/whoop/tests/conftest.py b/app-store/whoop/tests/conftest.py new file mode 100644 index 0000000..3183229 --- /dev/null +++ b/app-store/whoop/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + + +_app_dir = Path(__file__).resolve().parent.parent +if str(_app_dir) not in sys.path: + sys.path.insert(0, str(_app_dir)) diff --git a/app-store/whoop/tests/test_whoop_app_shells.py b/app-store/whoop/tests/test_whoop_app_shells.py new file mode 100644 index 0000000..24e0774 --- /dev/null +++ b/app-store/whoop/tests/test_whoop_app_shells.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import unittest +import sys +from pathlib import Path +from unittest.mock import AsyncMock, patch + + +_app_dir = Path(__file__).resolve().parent.parent +if str(_app_dir) not in sys.path: + sys.path.insert(0, str(_app_dir)) + +from truffile.app_runtime import AppAuthError +from truffile.app_runtime.testing import AppHarness, FakeBackgroundRuntime + +from whoop_background import app as whoop_bg_app +from whoop_bg_worker import BgRunResult, PreparedSubmission +from whoop_foreground import WhoopForegroundApp + + +class _BackgroundWorkerStub: + def __init__(self, results: list[BgRunResult]) -> None: + self._results = list(results) + self.close_calls = 0 + + async def verify(self) -> tuple[bool, str]: + return True, "WHOOP background verify OK" + + async def run_cycle(self) -> BgRunResult: + if self._results: + return self._results.pop(0) + return BgRunResult() + + async def close(self) -> None: + self.close_calls += 1 + + +class _ForegroundClientStub: + async def verify(self) -> tuple[bool, str]: + return False, "WHOOP access token is missing" + + async def close(self) -> None: + return None + + +class TestWhoopAppShells(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + whoop_bg_app.reset_for_test() + + async def asyncTearDown(self) -> None: + whoop_bg_app.reset_for_test() + + async def test_background_harness_captures_submission(self) -> None: + worker = _BackgroundWorkerStub( + [ + BgRunResult( + submissions=[ + PreparedSubmission( + text="WHOOP recovery scored: 72% recovery, RHR 54 bpm.", + priority=1, + ) + ] + ) + ] + ) + + with patch.object(whoop_bg_app, "build_worker", return_value=worker): + harness = AppHarness(bg_app=whoop_bg_app, logger_names=["whoop.background"]) + result = await harness.run_bg(cycles=1) + + self.assertTrue(result.success) + self.assertEqual(len(result.submissions), 1) + self.assertIn("WHOOP recovery scored", result.submissions[0]["text"]) + + async def test_background_runtime_reports_repeated_auth_failure(self) -> None: + worker = _BackgroundWorkerStub( + [ + BgRunResult(auth_error="WHOOP rejected the installed credentials"), + BgRunResult(auth_error="WHOOP rejected the installed credentials"), + BgRunResult(auth_error="WHOOP rejected the installed credentials"), + ] + ) + + with patch.object(whoop_bg_app, "build_worker", return_value=worker): + runtime = FakeBackgroundRuntime(whoop_bg_app, cycles=3) + runtime.run() + + self.assertEqual(len(runtime.all_reported_errors), 1) + self.assertIn("whoop authentication failure", runtime.all_reported_errors[0].error_message) + + async def test_reset_for_test_closes_worker(self) -> None: + worker = _BackgroundWorkerStub([BgRunResult()]) + + with patch.object(whoop_bg_app, "build_worker", return_value=worker): + runtime = FakeBackgroundRuntime(whoop_bg_app, cycles=1) + runtime.run() + + whoop_bg_app.reset_for_test() + + self.assertGreaterEqual(worker.close_calls, 1) + + async def test_foreground_status_raises_auth_error_for_missing_token(self) -> None: + app = WhoopForegroundApp(client=_ForegroundClientStub()) # type: ignore[arg-type] + + with patch.object(app._error_reporter, "report_foreground_exception", new=AsyncMock()): + with self.assertRaisesRegex(AppAuthError, "WHOOP access token is missing"): + await app.invoke_tool("whoop_status") + + async def test_foreground_bad_limit_returns_tool_error(self) -> None: + app = WhoopForegroundApp(client=_ForegroundClientStub()) # type: ignore[arg-type] + + result = await app.invoke_tool("list_sleep", limit=0) + + self.assertEqual(result["status"], "error") + self.assertIn("limit must be between 1 and 25", result["message"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/app-store/whoop/truffile.yaml b/app-store/whoop/truffile.yaml new file mode 100644 index 0000000..bcc8d26 --- /dev/null +++ b/app-store/whoop/truffile.yaml @@ -0,0 +1,91 @@ +metadata: + name: WHOOP + bundle_id: org.truffle.whoop.bridge + description: | + Read your WHOOP profile, recovery, sleep, cycle, and workout data + through a Truffle-managed WHOOP bridge. + During install, connect a WHOOP developer app and authorize access + to your WHOOP account. + icon_file: ./icon.png + foreground: + process: + cmd: + - python + - whoop_foreground.py + working_directory: / + environment: + PYTHONUNBUFFERED: "1" + +steps: + - name: Install Python dependencies + type: bash + run: | + pip install --no-cache-dir "httpx>=0.27.0" + + - name: Copy application files + type: files + files: + - source: ./config.py + destination: ./config.py + - source: ./whoop_auth.py + destination: ./whoop_auth.py + - source: ./whoop_client.py + destination: ./whoop_client.py + - source: ./whoop_foreground.py + destination: ./whoop_foreground.py + + - name: Configure WHOOP OAuth tokens + type: text + ui_state_on_show: user_interaction_ready + ui_state_on_complete: move_to_background + content: | + Run `python app-store/whoop/scripts/bootstrap_whoop_oauth.py` locally before deploy. + Paste each generated value below. + + This manual token bootstrap is temporary for the app-store PR. + For public release, Truffle should provide the official WHOOP developer app + and callback URL. + + The OAuth request includes the `offline` scope so WHOOP returns a refresh token. + It may not appear as a dashboard checkbox. + fields: + - name: client_id + label: WHOOP Client ID + type: text + env: WHOOP_CLIENT_ID + - name: client_secret + label: WHOOP Client Secret + type: password + env: WHOOP_CLIENT_SECRET + - name: redirect_uri + label: WHOOP Redirect URI + type: text + default: http://127.0.0.1:8765/callback + env: WHOOP_REDIRECT_URI + - name: access_token + label: WHOOP Access Token + type: password + env: WHOOP_ACCESS_TOKEN + - name: refresh_token + label: WHOOP Refresh Token + type: password + env: WHOOP_REFRESH_TOKEN + - name: access_token_expires_at + label: WHOOP Access Token Expires At + type: text + env: WHOOP_ACCESS_TOKEN_EXPIRES_AT + - name: token_scope + label: WHOOP Token Scope + type: text + default: offline read:profile read:body_measurement read:cycles read:recovery read:sleep read:workout + env: WHOOP_TOKEN_SCOPE + - name: token_type + label: WHOOP Token Type + type: text + default: bearer + env: WHOOP_TOKEN_TYPE + validator: + type: bash + run: python ./whoop_foreground.py --verify + timeout: 120 + error_message: Could not verify WHOOP OAuth credentials. diff --git a/app-store/whoop/whoop_auth.py b/app-store/whoop/whoop_auth.py new file mode 100644 index 0000000..91157db --- /dev/null +++ b/app-store/whoop/whoop_auth.py @@ -0,0 +1,164 @@ +"""WHOOP OAuth token helpers for manual-token Truffle installs.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import time +from typing import Any, Callable + +from truffile.app_runtime import AppAuthError, OAuth + +from config import WhoopConfig + + +class WhoopOAuth(OAuth): + APP_VAR_KEY = "whoop_oauth" + + def __init__( + self, + config: WhoopConfig | None = None, + *, + read_only: bool = False, + time_fn: Callable[[], float] | None = None, + ) -> None: + self._config = config or WhoopConfig.from_env() + self._time_fn = time_fn or time.time + super().__init__(self._config.token_store_path, read_only=read_only) + + @staticmethod + def token_from_payload(payload: dict[str, Any]) -> str: + return str(payload.get("access_token", "") or "").strip() + + def config_errors(self) -> list[str]: + errors: list[str] = [] + if not self._config.client_id: + errors.append("WHOOP_CLIENT_ID is missing") + if not self._config.client_secret: + errors.append("WHOOP_CLIENT_SECRET is missing") + if not self._config.redirect_uri: + errors.append("WHOOP_REDIRECT_URI is missing") + payload = self.get_oauth_payload() or {} + if not self.token_from_payload(payload): + errors.append("WHOOP access token is missing") + if not str(payload.get("refresh_token", "") or "").strip(): + errors.append("WHOOP refresh token is missing") + return errors + + def verify(self) -> tuple[bool, str]: + errors = self.config_errors() + if errors: + return False, "; ".join(errors) + return True, "WHOOP OAuth credentials loaded" + + def get_client_id(self) -> str: + return self._config.client_id + + def get_client_secret(self) -> str: + return self._config.client_secret + + def get_redirect_uri(self) -> str: + return self._config.redirect_uri + + def get_refresh_token(self) -> str: + payload = self.get_oauth_payload() or {} + token = str(payload.get("refresh_token", "") or "").strip() + if not token: + raise AppAuthError("WHOOP refresh token missing") + return token + + def can_refresh(self) -> bool: + return bool( + self._config.client_id + and self._config.client_secret + and str((self.get_oauth_payload() or {}).get("refresh_token", "") or "").strip() + ) + + def get_auth_headers(self) -> dict[str, str]: + token = self.get_access_token() + if not token: + return {} + return {"Authorization": f"Bearer {token}"} + + def token_expires_soon(self, *, leeway_seconds: float = 120.0) -> bool: + expires_at_value = self._token_expires_at_value() + if expires_at_value is None: + return False + return self._time_fn() + leeway_seconds >= expires_at_value + + def remember_token_response( + self, + payload: dict[str, Any], + *, + now: float | None = None, + ) -> dict[str, Any]: + current = self.get_oauth_payload() or {} + merged = dict(current) + merged.update({key: value for key, value in payload.items() if value is not None}) + if "expires_in" in payload: + try: + merged["expires_at"] = float(now if now is not None else self._time_fn()) + float(payload["expires_in"]) + except (TypeError, ValueError): + merged.pop("expires_at", None) + merged["token_type"] = str(merged.get("token_type", "bearer") or "bearer").strip() or "bearer" + self.save_oauth_payload(merged) + return merged + + def status(self) -> dict[str, Any]: + payload = self.get_oauth_payload() or {} + expires_at_value = self._token_expires_at_value(payload) + seconds_remaining = None + expires_at_utc = None + token_expired = None + token_expires_soon = None + if expires_at_value is not None: + now = self._time_fn() + seconds_remaining = max(0, int(expires_at_value - now)) + expires_at_utc = datetime.fromtimestamp(expires_at_value, tz=timezone.utc).isoformat() + token_expired = now >= expires_at_value + token_expires_soon = now + 120.0 >= expires_at_value + return { + "client_id_configured": bool(self._config.client_id), + "client_secret_configured": bool(self._config.client_secret), + "redirect_uri_configured": bool(self._config.redirect_uri), + "access_token_present": bool(self.token_from_payload(payload)), + "refresh_token_present": bool(str(payload.get("refresh_token", "") or "").strip()), + "token_expires_at": payload.get("expires_at"), + "token_expires_at_unix": expires_at_value, + "token_expires_at_utc": expires_at_utc, + "token_seconds_remaining": seconds_remaining, + "token_expired": token_expired, + "token_expires_soon": token_expires_soon, + "scope": str(payload.get("scope", "") or "").strip(), + "token_type": str(payload.get("token_type", "bearer") or "bearer").strip() or "bearer", + "token_store_path": str(self.token_file), + } + + def get_oauth_payload(self) -> dict[str, Any] | None: + payload = super().get_oauth_payload() + if payload: + return payload + env_payload = self._payload_from_env() + if env_payload and self.token_from_payload(env_payload) and not self._read_only: + self.save_oauth_payload(env_payload) + return env_payload + + def _payload_from_env(self) -> dict[str, Any] | None: + payload: dict[str, Any] = {} + if self._config.access_token: + payload["access_token"] = self._config.access_token + if self._config.refresh_token: + payload["refresh_token"] = self._config.refresh_token + if self._config.access_token_expires_at is not None: + payload["expires_at"] = self._config.access_token_expires_at + if self._config.token_scope: + payload["scope"] = self._config.token_scope + if self._config.token_type: + payload["token_type"] = self._config.token_type + return payload or None + + def _token_expires_at_value(self, payload: dict[str, Any] | None = None) -> float | None: + payload = payload if payload is not None else self.get_oauth_payload() or {} + try: + return float(payload.get("expires_at")) + except (TypeError, ValueError): + return None diff --git a/app-store/whoop/whoop_background.py b/app-store/whoop/whoop_background.py new file mode 100644 index 0000000..cc06249 --- /dev/null +++ b/app-store/whoop/whoop_background.py @@ -0,0 +1,100 @@ +# Draft background app shell. It is intentionally not enabled in truffile.yaml +# for the foreground-only PR. +"""Background WHOOP app shell.""" + +from __future__ import annotations + +import atexit +import asyncio +import threading +from typing import Any + +from truffile.app_runtime import BackgroundWorkerApp + +from whoop_bg_worker import BgRunResult, WhoopBackgroundWorker + + +class WhoopBackgroundApp(BackgroundWorkerApp[WhoopBackgroundWorker, BgRunResult]): + def __init__(self) -> None: + super().__init__("whoop", logger_name="whoop.background") + self._loop: asyncio.AbstractEventLoop | None = None + + def _run(self, coro: Any) -> Any: + try: + asyncio.get_running_loop() + except RuntimeError: + if self._loop is None or self._loop.is_closed(): + self._loop = asyncio.new_event_loop() + return self._loop.run_until_complete(coro) + + result: dict[str, Any] = {} + error: dict[str, BaseException] = {} + + def _runner() -> None: + try: + result["value"] = asyncio.run(coro) + except BaseException as exc: # noqa: BLE001 + error["exc"] = exc + + thread = threading.Thread(target=_runner, daemon=True) + thread.start() + thread.join() + if "exc" in error: + raise error["exc"] + return result.get("value") + + def build_worker(self) -> WhoopBackgroundWorker: + return WhoopBackgroundWorker() + + def verify_worker(self, worker: WhoopBackgroundWorker) -> tuple[bool, str]: + return self._run(worker.verify()) + + def run_cycle(self, worker: WhoopBackgroundWorker) -> BgRunResult: + return self._run(worker.run_cycle()) + + def handle_cycle_result(self, ctx: object, result: BgRunResult) -> None: + if result.auth_error: + self.report_auth_failure(ctx, result.auth_error) + return + + self.reset_auth_failures() + if result.error: + self.logger.error("WHOOP background cycle failed: %s", result.error) + return + + if not result.submissions: + self.logger.info("WHOOP background cycle produced no new signals") + return + + for submission in result.submissions: + self.submit_text( + ctx, + content=submission.text, + uris=submission.uris, + priority=submission.priority, + ) + + def reset_for_test(self) -> None: + worker = getattr(self, "_worker", None) + if worker is not None: + try: + self._run(worker.close()) + except Exception: + self.logger.exception("Failed to close WHOOP background worker during reset") + super().reset_for_test() + if self._loop is not None and not self._loop.is_closed(): + self._loop.close() + self._loop = None + + def cleanup(self) -> None: + self.reset_for_test() + + +app = WhoopBackgroundApp() + + +atexit.register(app.cleanup) + + +if __name__ == "__main__": + app.main() diff --git a/app-store/whoop/whoop_bg_worker.py b/app-store/whoop/whoop_bg_worker.py new file mode 100644 index 0000000..bc8259d --- /dev/null +++ b/app-store/whoop/whoop_bg_worker.py @@ -0,0 +1,457 @@ +# Draft background worker. It is intentionally not enabled in truffile.yaml +# for the foreground-only PR. +"""Background WHOOP worker that prepares ambient health signals.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import Any + +from truffile.app_runtime import AppAuthError +from truffle.app.background_pb2 import BackgroundContext + +from whoop_client import WhoopApiError, WhoopClient + +LOW_RECOVERY_THRESHOLD = 34.0 +LOW_SLEEP_PERFORMANCE_THRESHOLD = 70.0 +HIGH_STRAIN_THRESHOLD = 14.0 +SHORT_SLEEP_MILLI = 6 * 60 * 60 * 1000 + +_PRIORITY_LOW = getattr(BackgroundContext, "PRIORITY_LOW", getattr(BackgroundContext, "PRIORITY_DEFAULT", 0)) +_PRIORITY_DEFAULT = getattr(BackgroundContext, "PRIORITY_DEFAULT", getattr(BackgroundContext, "PRIORITY_HIGH", 1)) +_PRIORITY_HIGH = getattr(BackgroundContext, "PRIORITY_HIGH", _PRIORITY_DEFAULT) + + +@dataclass(frozen=True, slots=True) +class PreparedSubmission: + text: str + uris: tuple[str, ...] = () + priority: int = _PRIORITY_DEFAULT + + +@dataclass(frozen=True, slots=True) +class BgRunResult: + submissions: list[PreparedSubmission] = field(default_factory=list) + auth_error: str | None = None + error: str | None = None + + +class WhoopBackgroundWorker: + def __init__(self, *, client: Any | None = None) -> None: + self._client = client or WhoopClient() + self._seeded = False + self._seen_fingerprints: set[str] = set() + self._last_mismatch_key: str | None = None + + async def close(self) -> None: + close = getattr(self._client, "close", None) + if callable(close): + await close() + + async def verify(self) -> tuple[bool, str]: + try: + return await self._client.verify() + except Exception as exc: + return False, f"WHOOP background verification failed: {exc}" + + async def run_cycle(self) -> BgRunResult: + try: + summary = await self._client.get_recent_summary() + except AppAuthError as exc: + return BgRunResult(auth_error=str(exc)) + except WhoopApiError as exc: + if exc.status_code in {401, 403}: + return BgRunResult(auth_error=str(exc)) + return BgRunResult(error=f"WHOOP API error: HTTP {exc.status_code}") + except Exception as exc: + return BgRunResult(error=str(exc)) + + if not self._seeded: + self._seed(summary) + self._seeded = True + snapshot = self._build_snapshot(summary) + if not snapshot: + return BgRunResult() + return BgRunResult(submissions=[PreparedSubmission(text=snapshot, priority=_PRIORITY_LOW)]) + + submissions = self._build_changed_submissions(summary) + return BgRunResult(submissions=submissions) + + def _seed(self, summary: dict[str, Any]) -> None: + for kind, item in self._iter_items(summary): + fp = self._fingerprint(kind, item) + if fp: + self._seen_fingerprints.add(fp) + + recovery = self._dict_or_none(summary.get("latest_recovery")) + cycle = self._dict_or_none(summary.get("latest_cycle")) + self._last_mismatch_key = self._mismatch_key(recovery, cycle) + + def _build_changed_submissions(self, summary: dict[str, Any]) -> list[PreparedSubmission]: + submissions: list[PreparedSubmission] = [] + for kind, item in self._iter_items(summary): + fp = self._fingerprint(kind, item) + if not fp or fp in self._seen_fingerprints: + continue + self._seen_fingerprints.add(fp) + submission = self._submission_for(kind, item) + if submission is not None: + submissions.append(submission) + + recovery = self._dict_or_none(summary.get("latest_recovery")) + cycle = self._dict_or_none(summary.get("latest_cycle")) + mismatch_key = self._mismatch_key(recovery, cycle) + if mismatch_key and mismatch_key != self._last_mismatch_key: + self._last_mismatch_key = mismatch_key + submission = self._build_recovery_strain_mismatch(recovery, cycle) + if submission is not None: + submissions.append(submission) + + return submissions + + def _iter_items(self, summary: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: + items: list[tuple[str, dict[str, Any]]] = [] + for kind, key in ( + ("recovery", "latest_recovery"), + ("sleep", "latest_sleep"), + ("cycle", "latest_cycle"), + ): + item = self._dict_or_none(summary.get(key)) + if item and self._is_scored(item): + items.append((kind, item)) + + workouts = summary.get("recent_workouts") + if isinstance(workouts, list): + for workout in workouts: + item = self._dict_or_none(workout) + if item and self._is_scored(item): + items.append(("workout", item)) + return items + + def _submission_for(self, kind: str, item: dict[str, Any]) -> PreparedSubmission | None: + if kind == "recovery": + return self._build_recovery_submission(item) + if kind == "sleep": + return self._build_sleep_submission(item) + if kind == "workout": + return self._build_workout_submission(item) + if kind == "cycle": + return self._build_cycle_submission(item) + return None + + def _build_snapshot(self, summary: dict[str, Any]) -> str: + parts: list[str] = ["WHOOP current snapshot:"] + + recovery = self._dict_or_none(summary.get("latest_recovery")) + if recovery and self._is_scored(recovery): + score = self._dict_or_none(recovery.get("score")) or {} + parts.append( + self._compact_sentence( + "recovery", + [ + self._fmt_percent(score.get("recovery_score")), + self._fmt_bpm("RHR", score.get("resting_heart_rate")), + self._fmt_number("HRV", score.get("hrv_rmssd_milli"), "ms", decimals=1), + ], + ) + ) + + sleep = self._dict_or_none(summary.get("latest_sleep")) + if sleep and self._is_scored(sleep): + score = self._dict_or_none(sleep.get("score")) or {} + stage = self._dict_or_none(score.get("stage_summary")) or {} + parts.append( + self._compact_sentence( + "sleep", + [ + self._fmt_percent(score.get("sleep_performance_percentage"), label="performance"), + self._fmt_duration(self._as_float(stage.get("total_in_bed_time_milli")), label="in bed"), + self._fmt_duration(self._sleep_time_milli(stage), label="asleep"), + ], + ) + ) + + cycle = self._dict_or_none(summary.get("latest_cycle")) + if cycle and self._is_scored(cycle): + score = self._dict_or_none(cycle.get("score")) or {} + parts.append( + self._compact_sentence( + "day strain", + [ + self._fmt_number(None, score.get("strain"), None, decimals=1), + self._fmt_bpm("avg HR", score.get("average_heart_rate")), + ], + ) + ) + + workouts = [item for kind, item in self._iter_items(summary) if kind == "workout"] + if workouts: + workout = workouts[0] + score = self._dict_or_none(workout.get("score")) or {} + parts.append( + self._compact_sentence( + "latest workout", + [ + str(workout.get("sport_name") or "workout"), + self._fmt_number(None, score.get("strain"), "strain", decimals=1), + ], + ) + ) + + if len(parts) == 1: + return "" + return " ".join(parts) + + def _build_recovery_submission(self, recovery: dict[str, Any]) -> PreparedSubmission | None: + score = self._dict_or_none(recovery.get("score")) + if not score: + return None + + recovery_score = self._as_float(score.get("recovery_score")) + parts = [ + self._fmt_percent(recovery_score, label="recovery"), + self._fmt_bpm("RHR", score.get("resting_heart_rate")), + self._fmt_number("HRV", score.get("hrv_rmssd_milli"), "ms", decimals=1), + self._fmt_percent(score.get("spo2_percentage"), label="SpO2"), + self._fmt_number("skin temp", score.get("skin_temp_celsius"), "C", decimals=1), + ] + content = f"WHOOP recovery scored: {self._join_parts(parts)}." + priority = _PRIORITY_HIGH if recovery_score is not None and recovery_score < LOW_RECOVERY_THRESHOLD else _PRIORITY_DEFAULT + return PreparedSubmission(text=content, priority=priority) + + def _build_sleep_submission(self, sleep: dict[str, Any]) -> PreparedSubmission | None: + score = self._dict_or_none(sleep.get("score")) + if not score: + return None + stage = self._dict_or_none(score.get("stage_summary")) or {} + + asleep_milli = self._sleep_time_milli(stage) + performance = self._as_float(score.get("sleep_performance_percentage")) + parts = [ + self._fmt_percent(performance, label="performance"), + self._fmt_percent(score.get("sleep_consistency_percentage"), label="consistency"), + self._fmt_percent(score.get("sleep_efficiency_percentage"), label="efficiency"), + self._fmt_duration(asleep_milli, label="asleep"), + self._fmt_duration(self._as_float(stage.get("total_in_bed_time_milli")), label="in bed"), + self._fmt_duration(self._as_float(stage.get("total_rem_sleep_time_milli")), label="REM"), + self._fmt_duration(self._as_float(stage.get("total_slow_wave_sleep_time_milli")), label="SWS"), + self._fmt_duration(self._as_float(stage.get("total_awake_time_milli")), label="awake"), + self._fmt_int(stage.get("disturbance_count"), label="disturbances"), + ] + content = f"WHOOP sleep scored: {self._join_parts(parts)}." + high = (performance is not None and performance < LOW_SLEEP_PERFORMANCE_THRESHOLD) or ( + asleep_milli is not None and asleep_milli < SHORT_SLEEP_MILLI + ) + return PreparedSubmission(text=content, priority=_PRIORITY_HIGH if high else _PRIORITY_DEFAULT) + + def _build_workout_submission(self, workout: dict[str, Any]) -> PreparedSubmission | None: + score = self._dict_or_none(workout.get("score")) + if not score: + return None + + strain = self._as_float(score.get("strain")) + duration = self._duration_between(workout.get("start"), workout.get("end")) + parts = [ + str(workout.get("sport_name") or "workout"), + self._fmt_duration(duration, label=None), + self._fmt_number(None, strain, "strain", decimals=1), + self._fmt_bpm("avg HR", score.get("average_heart_rate")), + self._fmt_bpm("max HR", score.get("max_heart_rate")), + self._fmt_number(None, score.get("kilojoule"), "kJ", decimals=0), + self._fmt_percent(score.get("percent_recorded"), label="recorded"), + self._fmt_distance(score.get("distance_meter")), + self._format_zones(self._dict_or_none(score.get("zone_durations"))), + ] + content = f"WHOOP workout: {self._join_parts(parts)}." + priority = _PRIORITY_HIGH if strain is not None and strain >= HIGH_STRAIN_THRESHOLD else _PRIORITY_DEFAULT + return PreparedSubmission(text=content, priority=priority) + + def _build_cycle_submission(self, cycle: dict[str, Any]) -> PreparedSubmission | None: + score = self._dict_or_none(cycle.get("score")) + if not score: + return None + + open_label = "open cycle" if not cycle.get("end") else "closed cycle" + parts = [ + self._fmt_number("day strain", score.get("strain"), None, decimals=1), + self._fmt_bpm("avg HR", score.get("average_heart_rate")), + self._fmt_bpm("max HR", score.get("max_heart_rate")), + self._fmt_number(None, score.get("kilojoule"), "kJ", decimals=0), + open_label, + ] + return PreparedSubmission(text=f"WHOOP cycle strain updated: {self._join_parts(parts)}.") + + def _build_recovery_strain_mismatch( + self, + recovery: dict[str, Any] | None, + cycle: dict[str, Any] | None, + ) -> PreparedSubmission | None: + if not recovery or not cycle: + return None + recovery_score = self._as_float((self._dict_or_none(recovery.get("score")) or {}).get("recovery_score")) + strain = self._as_float((self._dict_or_none(cycle.get("score")) or {}).get("strain")) + if recovery_score is None or strain is None: + return None + if recovery_score >= LOW_RECOVERY_THRESHOLD or strain < HIGH_STRAIN_THRESHOLD: + return None + content = f"WHOOP load/recovery mismatch: {recovery_score:.0f}% recovery with {strain:.1f} day strain." + return PreparedSubmission(text=content, priority=_PRIORITY_HIGH) + + def _mismatch_key(self, recovery: dict[str, Any] | None, cycle: dict[str, Any] | None) -> str | None: + mismatch = self._build_recovery_strain_mismatch(recovery, cycle) + if mismatch is None: + return None + recovery_score = (self._dict_or_none(recovery.get("score")) or {}).get("recovery_score") if recovery else None + strain = (self._dict_or_none(cycle.get("score")) or {}).get("strain") if cycle else None + return f"{recovery.get('cycle_id')}:{cycle.get('id')}:{recovery_score}:{strain}" if recovery and cycle else None + + def _fingerprint(self, kind: str, item: dict[str, Any]) -> str: + if kind == "recovery": + score = self._dict_or_none(item.get("score")) or {} + basis = { + "kind": kind, + "cycle_id": item.get("cycle_id"), + "updated_at": item.get("updated_at"), + "recovery_score": score.get("recovery_score"), + "resting_heart_rate": score.get("resting_heart_rate"), + "hrv_rmssd_milli": score.get("hrv_rmssd_milli"), + } + elif kind in {"sleep", "workout"}: + score = self._dict_or_none(item.get("score")) or {} + basis = {"kind": kind, "id": item.get("id"), "updated_at": item.get("updated_at"), "score": score} + elif kind == "cycle": + score = self._dict_or_none(item.get("score")) or {} + basis = { + "kind": kind, + "id": item.get("id"), + "updated_at": item.get("updated_at"), + "strain": score.get("strain"), + "average_heart_rate": score.get("average_heart_rate"), + "max_heart_rate": score.get("max_heart_rate"), + } + else: + basis = {"kind": kind, "item": item} + raw = json.dumps(basis, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + @staticmethod + def _is_scored(item: dict[str, Any]) -> bool: + return str(item.get("score_state") or "").upper() == "SCORED" and isinstance(item.get("score"), dict) + + @staticmethod + def _dict_or_none(value: Any) -> dict[str, Any] | None: + return value if isinstance(value, dict) else None + + @staticmethod + def _as_float(value: Any) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _fmt_percent(value: Any, *, label: str | None = None) -> str: + numeric = WhoopBackgroundWorker._as_float(value) + if numeric is None: + return "" + rendered = f"{numeric:.0f}%" + return f"{rendered} {label}" if label else rendered + + @staticmethod + def _fmt_bpm(label: str, value: Any) -> str: + numeric = WhoopBackgroundWorker._as_float(value) + if numeric is None: + return "" + return f"{label} {numeric:.0f} bpm" + + @staticmethod + def _fmt_number(label: str | None, value: Any, unit: str | None, *, decimals: int) -> str: + numeric = WhoopBackgroundWorker._as_float(value) + if numeric is None: + return "" + rendered = f"{numeric:.{decimals}f}" + if unit: + rendered = f"{rendered} {unit}" + return f"{label} {rendered}" if label else rendered + + @staticmethod + def _fmt_int(value: Any, *, label: str) -> str: + numeric = WhoopBackgroundWorker._as_float(value) + if numeric is None: + return "" + return f"{numeric:.0f} {label}" + + @staticmethod + def _fmt_duration(value: Any, *, label: str | None) -> str: + milli = WhoopBackgroundWorker._as_float(value) + if milli is None: + return "" + total_minutes = max(0, int(round(milli / 60_000))) + hours, minutes = divmod(total_minutes, 60) + if hours: + rendered = f"{hours}h{minutes:02d}m" + else: + rendered = f"{minutes}m" + return f"{rendered} {label}" if label else rendered + + @staticmethod + def _fmt_distance(value: Any) -> str: + meters = WhoopBackgroundWorker._as_float(value) + if meters is None: + return "" + if meters >= 1000: + return f"{meters / 1000:.2f} km" + return f"{meters:.0f} m" + + @staticmethod + def _duration_between(start: Any, end: Any) -> float | None: + from datetime import datetime + + if not isinstance(start, str) or not isinstance(end, str): + return None + try: + start_dt = datetime.fromisoformat(start.replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(end.replace("Z", "+00:00")) + except ValueError: + return None + return max(0.0, (end_dt - start_dt).total_seconds() * 1000) + + @staticmethod + def _sleep_time_milli(stage: dict[str, Any]) -> float | None: + values = [ + WhoopBackgroundWorker._as_float(stage.get("total_light_sleep_time_milli")), + WhoopBackgroundWorker._as_float(stage.get("total_slow_wave_sleep_time_milli")), + WhoopBackgroundWorker._as_float(stage.get("total_rem_sleep_time_milli")), + ] + present = [value for value in values if value is not None] + if not present: + return None + return sum(present) + + @staticmethod + def _format_zones(zones: dict[str, Any] | None) -> str: + if not zones: + return "" + ordered = [ + ("Z5", zones.get("zone_five_milli")), + ("Z4", zones.get("zone_four_milli")), + ("Z3", zones.get("zone_three_milli")), + ("Z2", zones.get("zone_two_milli")), + ] + rendered = [ + f"{label} {WhoopBackgroundWorker._fmt_duration(value, label=None)}" + for label, value in ordered + if WhoopBackgroundWorker._as_float(value) + ] + return "zones " + ", ".join(rendered) if rendered else "" + + @staticmethod + def _join_parts(parts: list[str]) -> str: + return ", ".join(part for part in parts if part) + + @staticmethod + def _compact_sentence(label: str, parts: list[str]) -> str: + body = WhoopBackgroundWorker._join_parts(parts) + return f"{label}: {body}." if body else "" diff --git a/app-store/whoop/whoop_client.py b/app-store/whoop/whoop_client.py new file mode 100644 index 0000000..f839a35 --- /dev/null +++ b/app-store/whoop/whoop_client.py @@ -0,0 +1,348 @@ +"""Thin async WHOOP client with token refresh support.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any, Callable +from urllib.parse import urlencode + +import httpx + +from truffile.app_runtime import AppAuthError, AppRuntimeFailure, HttpTransport + +from config import WhoopConfig +from whoop_auth import WhoopOAuth + + +class WhoopApiError(AppRuntimeFailure): + def __init__( + self, + message: str, + *, + status_code: int, + response_text: str = "", + ) -> None: + super().__init__(message) + self.status_code = status_code + self.response_text = response_text + + +class _HttpxTransport: + def __init__(self, *, timeout: float = 30.0) -> None: + self._client = httpx.AsyncClient(timeout=timeout) + + async def request( + self, + method: str, + url: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + content: str | None = None, + ) -> httpx.Response: + return await self._client.request( + method=method.upper(), + url=url, + params=params, + json=json, + headers=headers, + content=content, + ) + + async def close(self) -> None: + await self._client.aclose() + + +def _mask_token(token: str) -> str: + cleaned = token.strip() + if len(cleaned) <= 8: + return cleaned[:2] + "..." if cleaned else "none" + return f"{cleaned[:4]}...{cleaned[-4:]}" + + +class WhoopClient: + def __init__( + self, + *, + config: WhoopConfig | None = None, + auth: WhoopOAuth | None = None, + http: HttpTransport | None = None, + time_fn: Callable[[], float] | None = None, + ) -> None: + self._config = config or WhoopConfig.from_env() + self._time_fn = time_fn or time.time + self._auth = auth or WhoopOAuth(self._config, time_fn=self._time_fn) + self._http = http or _HttpxTransport() + self._refresh_lock = asyncio.Lock() + + @property + def auth(self) -> WhoopOAuth: + return self._auth + + async def close(self) -> None: + try: + await self._http.close() + except Exception: + pass + + async def verify(self) -> tuple[bool, str]: + errors = self._auth.config_errors() + if errors: + return False, "; ".join(errors) + try: + profile = await self.get_profile_basic() + except AppAuthError as exc: + return False, f"WHOOP verification failed: {exc}" + except WhoopApiError as exc: + if exc.status_code: + return False, f"WHOOP verification failed: HTTP {exc.status_code} {exc}" + return False, f"WHOOP verification failed: {exc}" + except Exception as exc: + return False, f"WHOOP verification failed: {exc}" + + masked = _mask_token(self._auth.get_access_token()) + return True, ( + "WHOOP credentials verified. " + f"user_id={profile.get('user_id')} " + f"email={profile.get('email')} " + f"token={masked}" + ) + + async def get_profile_basic(self) -> dict[str, Any]: + return await self._request("GET", "/v2/user/profile/basic") + + async def get_body_measurements(self) -> dict[str, Any]: + return await self._request("GET", "/v2/user/measurement/body") + + async def list_cycles( + self, + *, + limit: int | None = None, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + return await self._request( + "GET", + "/v2/cycle", + params={ + "limit": limit, + "start": start, + "end": end, + "nextToken": next_token, + }, + ) + + async def get_cycle_by_id(self, cycle_id: int) -> dict[str, Any]: + return await self._request("GET", f"/v2/cycle/{cycle_id}") + + async def list_recovery( + self, + *, + limit: int | None = None, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + return await self._request( + "GET", + "/v2/recovery", + params={ + "limit": limit, + "start": start, + "end": end, + "nextToken": next_token, + }, + ) + + async def get_recovery_for_cycle(self, cycle_id: int) -> dict[str, Any]: + return await self._request("GET", f"/v2/cycle/{cycle_id}/recovery") + + async def list_sleep( + self, + *, + limit: int | None = None, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + return await self._request( + "GET", + "/v2/activity/sleep", + params={ + "limit": limit, + "start": start, + "end": end, + "nextToken": next_token, + }, + ) + + async def get_sleep_by_id(self, sleep_id: str) -> dict[str, Any]: + return await self._request("GET", f"/v2/activity/sleep/{sleep_id}") + + async def get_sleep_for_cycle(self, cycle_id: int) -> dict[str, Any]: + return await self._request("GET", f"/v2/cycle/{cycle_id}/sleep") + + async def list_workouts( + self, + *, + limit: int | None = None, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + return await self._request( + "GET", + "/v2/activity/workout", + params={ + "limit": limit, + "start": start, + "end": end, + "nextToken": next_token, + }, + ) + + async def get_workout_by_id(self, workout_id: str) -> dict[str, Any]: + return await self._request("GET", f"/v2/activity/workout/{workout_id}") + + async def get_recent_summary(self) -> dict[str, Any]: + profile = await self.get_profile_basic() + body = await self.get_body_measurements() + cycles = await self.list_cycles(limit=1) + latest_cycle = self._first_record(cycles) + latest_recovery = None + latest_sleep = None + if isinstance(latest_cycle, dict) and latest_cycle.get("id") is not None: + cycle_id = int(latest_cycle["id"]) + latest_recovery = await self.get_recovery_for_cycle(cycle_id) + latest_sleep = await self.get_sleep_for_cycle(cycle_id) + workouts = await self.list_workouts(limit=3) + return { + "profile": profile, + "body_measurements": body, + "latest_cycle": latest_cycle, + "latest_recovery": latest_recovery, + "latest_sleep": latest_sleep, + "recent_workouts": workouts.get("records", []), + "recent_workouts_next_token": workouts.get("next_token"), + } + + def auth_status(self) -> dict[str, Any]: + return self._auth.status() + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + retry_on_auth: bool = True, + ) -> dict[str, Any]: + if not self._auth.get_access_token() and self._auth.can_refresh(): + await self._refresh_access_token() + + if self._auth.token_expires_soon(): + await self._refresh_access_token() + + response = await self._send_request(method, path, params=params) + if response.status_code == 401 and retry_on_auth: + await self._refresh_access_token(force=True) + response = await self._send_request(method, path, params=params) + + if response.status_code in {401, 403}: + raise AppAuthError( + "WHOOP rejected the installed credentials. Reinstall the app with fresh WHOOP tokens." + ) + + if not response.is_success: + raise WhoopApiError( + f"WHOOP API request failed for {path}", + status_code=response.status_code, + response_text=self._response_text(response), + ) + + data = response.json() + if not isinstance(data, dict): + raise AppRuntimeFailure(f"WHOOP API returned a non-object payload for {path}") + return data + + async def _send_request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + ) -> Any: + clean_params = {key: value for key, value in (params or {}).items() if value is not None} + headers = { + "Accept": "application/json", + } + headers.update(self._auth.get_auth_headers()) + return await self._http.request( + method.upper(), + f"{self._config.api_base.rstrip('/')}{path}", + params=clean_params or None, + headers=headers, + ) + + async def _refresh_access_token(self, *, force: bool = False) -> dict[str, Any]: + async with self._refresh_lock: + if not force and self._auth.get_access_token() and not self._auth.token_expires_soon(): + payload = self._auth.get_oauth_payload() + return payload if payload is not None else {} + + if not self._auth.can_refresh(): + raise AppAuthError( + "WHOOP refresh requires WHOOP_CLIENT_ID, WHOOP_CLIENT_SECRET, and WHOOP_REFRESH_TOKEN." + ) + + refresh_payload = { + "grant_type": "refresh_token", + "refresh_token": self._auth.get_refresh_token(), + "client_id": self._auth.get_client_id(), + "client_secret": self._auth.get_client_secret(), + "scope": "offline", + } + response = await self._http.request( + "POST", + self._config.token_url, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + content=urlencode(refresh_payload), + ) + + if response.status_code in {401, 403}: + raise AppAuthError( + "WHOOP token refresh failed. Check the client credentials and refresh token." + ) + + if not response.is_success: + raise WhoopApiError( + "WHOOP token refresh failed", + status_code=response.status_code, + response_text=self._response_text(response), + ) + + data = response.json() + if not isinstance(data, dict) or not str(data.get("access_token", "") or "").strip(): + raise AppRuntimeFailure("WHOOP token refresh returned an invalid payload") + return self._auth.remember_token_response(data, now=self._time_fn()) + + @staticmethod + def _first_record(payload: dict[str, Any]) -> dict[str, Any] | None: + records = payload.get("records") + if not isinstance(records, list) or not records: + return None + first = records[0] + return first if isinstance(first, dict) else None + + @staticmethod + def _response_text(response: Any) -> str: + try: + return str(response.text or "") + except Exception: + return "" diff --git a/app-store/whoop/whoop_foreground.py b/app-store/whoop/whoop_foreground.py new file mode 100644 index 0000000..b2da2c3 --- /dev/null +++ b/app-store/whoop/whoop_foreground.py @@ -0,0 +1,386 @@ +"""Foreground WHOOP app exposing read-only health data tools.""" + +from __future__ import annotations + +import argparse +import atexit +import asyncio +import sys +from typing import Any + +from truffile.app_runtime import ForegroundApp, ToolSpec, err, ok, phosphor_icon_url +from truffile.app_runtime.errors import AppAuthError + +from whoop_client import WhoopApiError, WhoopClient + + +WHOOP_CONTEXT_NOTE = ( + "WHOOP strain is scored from 0-21; this app treats strain >=14 as high for alerts. " + "Recovery is tied to the scored sleep/recovery period for a cycle, while an open cycle's " + "strain is current-day load. Stick to WHOOP data and avoid medical claims." +) + + +def _validate_limit(limit: int) -> None: + if limit < 1 or limit > 25: + raise ValueError("limit must be between 1 and 25") + + +class WhoopForegroundApp(ForegroundApp): + def __init__(self, *, client: WhoopClient | None = None) -> None: + super().__init__("whoop", logger_name="whoop.foreground") + self._client = client + self._register_tools() + + def _get_client(self) -> WhoopClient: + if self._client is None: + self._client = WhoopClient() + return self._client + + async def aclose(self) -> None: + client = self._client + if client is None: + return + self._client = None + await client.close() + + async def _tool_error(self, exc: BaseException) -> dict[str, Any]: + if isinstance(exc, AppAuthError): + raise exc + if isinstance(exc, WhoopApiError): + return err( + f"WHOOP API error: {exc.status_code}", + kind="http", + status_code=exc.status_code, + response=exc.response_text[:1500], + ) + return err(str(exc)) + + @staticmethod + def _list_result(label: str, payload: dict[str, Any]) -> dict[str, Any]: + records = payload.get("records", []) + count = len(records) if isinstance(records, list) else 0 + return ok( + f"Fetched {count} {label}", + records=records, + count=count, + next_token=payload.get("next_token"), + ) + + def _register_tools(self) -> None: + @self.tool( + ToolSpec( + name="whoop_status", + description="Verify WHOOP connectivity and show installed token status, including human-readable token expiry.", + icon=phosphor_icon_url("heartbeat"), + readonly=True, + ) + ) + async def whoop_status() -> dict[str, Any]: + try: + client = self._get_client() + ok_state, message = await client.verify() + if not ok_state: + raise AppAuthError(message) + profile = await client.get_profile_basic() + return ok(message, auth=client.auth_status(), profile=profile) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_profile_basic", + description="Fetch the WHOOP user's basic profile.", + icon=phosphor_icon_url("user-circle"), + readonly=True, + ) + ) + async def get_profile_basic() -> dict[str, Any]: + try: + return ok("WHOOP profile fetched", profile=await self._get_client().get_profile_basic()) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_body_measurements", + description="Fetch the WHOOP user's body measurements and max heart rate.", + icon=phosphor_icon_url("ruler"), + readonly=True, + ) + ) + async def get_body_measurements() -> dict[str, Any]: + try: + return ok( + "WHOOP body measurements fetched", + measurements=await self._get_client().get_body_measurements(), + ) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="list_cycles", + description=( + "List recent WHOOP cycles with optional time range filters. " + f"{WHOOP_CONTEXT_NOTE}" + ), + icon=phosphor_icon_url("repeat"), + readonly=True, + ) + ) + async def list_cycles( + limit: int = 10, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + try: + _validate_limit(limit) + payload = await self._get_client().list_cycles( + limit=limit, + start=start, + end=end, + next_token=next_token, + ) + return self._list_result("cycles", payload) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_cycle_by_id", + description=( + "Fetch a single WHOOP cycle by numeric cycle id. " + f"{WHOOP_CONTEXT_NOTE}" + ), + icon=phosphor_icon_url("clock-counter-clockwise"), + readonly=True, + ) + ) + async def get_cycle_by_id(cycle_id: int) -> dict[str, Any]: + try: + return ok("WHOOP cycle fetched", cycle=await self._get_client().get_cycle_by_id(cycle_id)) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="list_recovery", + description=( + "List recent WHOOP recovery records with optional time range filters. " + f"{WHOOP_CONTEXT_NOTE}" + ), + icon=phosphor_icon_url("battery-high"), + readonly=True, + ) + ) + async def list_recovery( + limit: int = 10, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + try: + _validate_limit(limit) + payload = await self._get_client().list_recovery( + limit=limit, + start=start, + end=end, + next_token=next_token, + ) + return self._list_result("recovery records", payload) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_recovery_for_cycle", + description=( + "Fetch the WHOOP recovery record associated with a cycle id. " + f"{WHOOP_CONTEXT_NOTE}" + ), + icon=phosphor_icon_url("battery-charging"), + readonly=True, + ) + ) + async def get_recovery_for_cycle(cycle_id: int) -> dict[str, Any]: + try: + return ok( + "WHOOP recovery fetched", + recovery=await self._get_client().get_recovery_for_cycle(cycle_id), + ) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="list_sleep", + description=( + "List recent WHOOP sleep records with optional time range filters. " + "Use this for sleep performance, efficiency, REM, SWS, awake time, and sleep duration comparisons." + ), + icon=phosphor_icon_url("moon-stars"), + readonly=True, + ) + ) + async def list_sleep( + limit: int = 10, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + try: + _validate_limit(limit) + payload = await self._get_client().list_sleep( + limit=limit, + start=start, + end=end, + next_token=next_token, + ) + return self._list_result("sleep records", payload) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_sleep_by_id", + description="Fetch a WHOOP sleep record by sleep UUID, including sleep stages and sleep score details.", + icon=phosphor_icon_url("bed"), + readonly=True, + ) + ) + async def get_sleep_by_id(sleep_id: str) -> dict[str, Any]: + try: + return ok("WHOOP sleep fetched", sleep=await self._get_client().get_sleep_by_id(sleep_id)) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_sleep_for_cycle", + description="Fetch the WHOOP sleep record associated with a cycle id, including sleep stages and sleep score details.", + icon=phosphor_icon_url("moon"), + readonly=True, + ) + ) + async def get_sleep_for_cycle(cycle_id: int) -> dict[str, Any]: + try: + return ok("WHOOP cycle sleep fetched", sleep=await self._get_client().get_sleep_for_cycle(cycle_id)) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="list_workouts", + description=( + "List recent WHOOP workouts with optional time range filters. " + "Use WHOOP strain, duration, heart rate, zones, and energy as data; avoid medical claims." + ), + icon=phosphor_icon_url("barbell"), + readonly=True, + ) + ) + async def list_workouts( + limit: int = 10, + start: str | None = None, + end: str | None = None, + next_token: str | None = None, + ) -> dict[str, Any]: + try: + _validate_limit(limit) + payload = await self._get_client().list_workouts( + limit=limit, + start=start, + end=end, + next_token=next_token, + ) + return self._list_result("workouts", payload) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_workout_by_id", + description=( + "Fetch a WHOOP workout by workout UUID. " + "Use WHOOP strain, duration, heart rate, zones, and energy as data; avoid medical claims." + ), + icon=phosphor_icon_url("person-simple-run"), + readonly=True, + ) + ) + async def get_workout_by_id(workout_id: str) -> dict[str, Any]: + try: + return ok( + "WHOOP workout fetched", + workout=await self._get_client().get_workout_by_id(workout_id), + ) + except Exception as exc: + return await self._tool_error(exc) + + @self.tool( + ToolSpec( + name="get_recent_whoop_summary", + description=( + "Fetch a compact WHOOP snapshot: profile, body measurements, latest cycle, latest recovery, " + f"latest sleep, and recent workouts. {WHOOP_CONTEXT_NOTE}" + ), + icon=phosphor_icon_url("chart-line"), + readonly=True, + ) + ) + async def get_recent_whoop_summary() -> dict[str, Any]: + try: + return ok("WHOOP summary fetched", summary=await self._get_client().get_recent_summary()) + except Exception as exc: + return await self._tool_error(exc) + + +app = WhoopForegroundApp() + + +def _cleanup() -> None: + try: + asyncio.run(app.aclose()) + except Exception: + pass + + +atexit.register(_cleanup) + + +async def _verify_async() -> int: + client = WhoopClient() + try: + ok_state, message = await client.verify() + finally: + await client.close() + print(message, flush=True) + return 0 if ok_state else 1 + + +def verify() -> int: + return asyncio.run(_verify_async()) + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="WHOOP foreground app") + parser.add_argument("--verify", action="store_true", help="Verify the installed WHOOP OAuth credentials") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + try: + args = _parse_args(argv) + if args.verify: + return verify() + app.run() + return 0 + except Exception as exc: + print(str(exc), file=sys.stderr, flush=True) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())