Skill: gavinchengcool/openclaw-oura (version 0.1.0)

--- name: openclaw-oura description: Oura Ring data source for OpenClaw (Tier 1). Use to connect an Oura account using an Oura Personal Access Token, fetch Oura v2 usercollection data (sleep, readiness, activity), normalize it into a stable daily JSON shape for the Wellness hub, and render a short summary message for any chat channel. --- # Oura (Personal Access Token) This is a **source** skill. It fetches Oura data and outputs normalized daily JSON + a human-readable digest. It does not hardcode the destination channel. ## Configuration Required env var: - `OURA_ACCESS_TOKEN` Optional: - `OURA_TZ` (default: `Asia/Shanghai`) ## Fetch + normalize for a day ```bash python3 scripts/oura_fetch_daily.py --date today --out /tmp/oura_raw_today.json python3 scripts/oura_normalize_daily.py /tmp/oura_raw_today.json --out /tmp/oura_today.json python3 scripts/oura_render.py /tmp/oura_today.json --format markdown --channel generic ``` ## Notes - API reference: `references/oura_api.md` - Output schema: `references/output_schema.md`

path: references/oura_api.md
content: # Oura API (quick reference)

Oura v2 API base:
- `https://api.ouraring.com/v2`

Auth:
- Personal Access Token (PAT)
- Send: `Authorization: Bearer <token>`

Common endpoints (usercollection):
- `GET /v2/usercollection/sleep?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD`
- `GET /v2/usercollection/readiness?start_date=...&end_date=...`
- `GET /v2/usercollection/daily_activity?start_date=...&end_date=...`

Notes:
- Response typically shaped like `{ data: [...] }`.
- Record date keys vary by endpoint (e.g., `day` or `summary_date`).


path: references/output_schema.md
content: # Output schema (for Wellness hub)

`oura_normalize_daily.py` outputs:

```json
{
  "date": "YYYY-MM-DD",
  "timezone": "IANA/Zone",
  "generated_at": "ISO-8601",
  "sleep": {
    "duration_minutes": "number|null",
    "efficiency": "number|null",
    "score": "number|null",
    "source": "oura"
  },
  "recovery": {
    "score": "number|null",
    "source": "oura"
  },
  "activity": {
    "steps": "number|null",
    "active_calories_kcal": "number|null",
    "source": "oura"
  },
  "sources_present": ["oura"],
  "source": {"raw_files": ["string"]}
}
```


path: scripts/oura_fetch_daily.py
content: #!/usr/bin/env python3
"""Fetch Oura v2 daily data (sleep, readiness, activity) for a local day.

Auth: Personal Access Token (OURA_ACCESS_TOKEN).

No third-party deps.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from typing import Any, Dict, Optional, Tuple

try:
    from zoneinfo import ZoneInfo
except Exception:  # pragma: no cover
    ZoneInfo = None  # type: ignore


API_BASE = "https://api.ouraring.com/v2"


def must_env(name: str) -> str:
    v = os.environ.get(name)
    if not v:
        print(f"Missing env var: {name}", file=sys.stderr)
        sys.exit(2)
    return v


def _tzinfo(tz_name: str):
    if ZoneInfo is None:
        return None
    try:
        return ZoneInfo(tz_name)
    except Exception:
        return None


def resolve_date(s: str, tz_name: str) -> str:
    s = s.strip().lower()
    tz = _tzinfo(tz_name)
    now = datetime.now(tz) if tz else datetime.now()
    if s == "today":
        return now.strftime("%Y-%m-%d")
    if s == "yesterday":
        return (now - timedelta(days=1)).strftime("%Y-%m-%d")
    datetime.strptime(s, "%Y-%m-%d")
    return s


def day_range(date_yyyy_mm_dd: str) -> Tuple[str, str]:
    # Oura v2 endpoints commonly accept start_date/end_date as YYYY-MM-DD.
    # end_date is typically inclusive; to be safe, we query [date, date].
    return date_yyyy_mm_dd, date_yyyy_mm_dd


def http_get_json(url: str, token: str) -> Any:
    req = urllib.request.Request(url, method="GET")
    req.add_header("Authorization", f"Bearer {token}")
    req.add_header("Accept", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {e.code} GET {url}: {raw}") from e


def fetch_collection(path: str, token: str, start_date: str, end_date: str) -> Any:
    params = {"start_date": start_date, "end_date": end_date}
    url = API_BASE + path + "?" + urllib.parse.urlencode(params)
    return {"url": url, "data": http_get_json(url, token)}


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--date", required=True, help="today|yesterday|YYYY-MM-DD")
    ap.add_argument("--out", required=True)
    ap.add_argument("--tz", default=os.environ.get("OURA_TZ", "Asia/Shanghai"))
    args = ap.parse_args()

    [REDACTED_SECRET]"OURA_ACCESS_TOKEN")
    day = resolve_date(args.date, args.tz)
    start_date, end_date = day_range(day)

    bundle: Dict[str, Any] = {
        "requested_date": day,
        "requested_tz": args.tz,
        "fetched_at": datetime.utcnow().isoformat() + "Z",
        "api_base": API_BASE,
        "endpoints": {
            "sleep": fetch_collection("/usercollection/sleep", token, start_date, end_date),
            "readiness": fetch_collection("/usercollection/readiness", token, start_date, end_date),
            "activity": fetch_collection("/usercollection/daily_activity", token, start_date, end_date),
        },
    }

    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(bundle, f, indent=2, sort_keys=True)
        f.write("\n")


if __name__ == "__main__":
    main()


path: scripts/oura_normalize_daily.py
content: #!/usr/bin/env python3
"""Normalize Oura raw bundle into Wellness-like daily fields.

Best-effort mapping: pick first record matching requested_date.

Usage:
  python3 scripts/oura_normalize_daily.py raw.json --out oura_day.json
"""

from __future__ import annotations

import argparse
import json
from datetime import datetime
from typing import Any, Dict, Optional


def pick_record(data: Any, requested_date: str) -> Optional[Dict[str, Any]]:
    if not isinstance(data, dict):
        return None
    recs = data.get("data")
    if not isinstance(recs, list):
        return None
    for r in recs:
        if isinstance(r, dict) and (r.get("day") == requested_date or r.get("summary_date") == requested_date):
            return r
    for r in recs:
        if isinstance(r, dict):
            return r
    return None


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("raw")
    ap.add_argument("--out", required=True)
    args = ap.parse_args()

    raw = json.load(open(args.raw, "r", encoding="utf-8"))
    date = raw.get("requested_date")
    tz = raw.get("requested_tz")

    ep = raw.get("endpoints") or {}
    sleep = pick_record(((ep.get("sleep") or {}).get("data")), date)
    readiness = pick_record(((ep.get("readiness") or {}).get("data")), date)
    activity = pick_record(((ep.get("activity") or {}).get("data")), date)

    out: Dict[str, Any] = {
        "date": date,
        "timezone": tz,
        "generated_at": datetime.utcnow().isoformat() + "Z",
        "sleep": {
            "duration_minutes": (sleep or {}).get("total_sleep_duration") if sleep else None,
            "efficiency": (sleep or {}).get("efficiency") if sleep else None,
            "score": (sleep or {}).get("score") if sleep else None,
            "source": "oura",
        },
        "recovery": {
            "score": (readiness or {}).get("score") if readiness else None,
            "source": "oura",
        },
        "activity": {
            "steps": (activity or {}).get("steps") if activity else None,
            "active_calories_kcal": (activity or {}).get("active_calories") if activity else None,
            "source": "oura",
        },
        "sources_present": ["oura"],
        "source": {"raw_files": [args.raw]},
    }

    json.dump(out, open(args.out, "w", encoding="utf-8"), indent=2, sort_keys=True)
    open(args.out, "a", encoding="utf-8").write("\n")


if __name__ == "__main__":
    main()


path: scripts/oura_render.py
content: #!/usr/bin/env python3
"""Render normalized Oura daily JSON.

Usage:
  python3 scripts/oura_render.py day.json --format markdown --channel generic
"""

from __future__ import annotations

import argparse
import json
from typing import Any, Dict


def style(channel: str, markdown: bool) -> Dict[str, Any]:
    c = (channel or "generic").lower()
    if not markdown or c == "telegram":
        return {"md": False, "bL": "", "bR": "", "bullet": "-"}
    if c in ("slack", "whatsapp"):
        return {"md": True, "bL": "*", "bR": "*", "bullet": "-"}
    return {"md": True, "bL": "**", "bR": "**", "bullet": "-"}


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("input")
    ap.add_argument("--format", choices=["text", "markdown"], default="markdown")
    ap.add_argument("--channel", default="generic")
    args = ap.parse_args()

    doc = json.load(open(args.input, "r", encoding="utf-8"))
    st = style(args.channel, args.format == "markdown")

    date = doc.get("date") or "(unknown date)"
    sleep = doc.get("sleep") or {}
    rec = doc.get("recovery") or {}
    act = doc.get("activity") or {}

    def b(s: str) -> str:
        return f"{st['bL']}{s}{st['bR']}" if st["md"] and st["bL"] else s

    lines = [b(f"Oura — {date}")]

    if sleep.get("duration_minutes") is not None:
        lines.append(f"{st['bullet']} Sleep: {sleep.get('duration_minutes')} min (score {sleep.get('score','—')})")
    if rec.get("score") is not None:
        lines.append(f"{st['bullet']} Readiness: {rec.get('score')}")
    if act.get("steps") is not None:
        lines.append(f"{st['bullet']} Steps: {act.get('steps')}")

    if len(lines) == 1:
        lines.append(f"{st['bullet']} No data found for this day")

    print("\n".join(lines).strip() + "\n", end="")


if __name__ == "__main__":
    main()


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