Skill: gavinchengcool/openclaw-fitbit (version 0.1.0)

--- name: openclaw-fitbit description: Official Fitbit OAuth integration for OpenClaw (Tier 1). Use to connect/authorize Fitbit, store+refresh tokens locally, fetch daily activity + sleep summaries, normalize into a stable daily JSON shape for the Wellness hub, and render a short digest for any chat channel. --- # Fitbit (Official API) This is a **source** skill. It connects to Fitbit, fetches data, normalizes it, and renders a short message. ## Configuration Required env vars: - `FITBIT_CLIENT_ID` - `FITBIT_CLIENT_SECRET` - `FITBIT_REDIRECT_URI` Optional: - `FITBIT_TOKEN_PATH` (default: `~/.config/openclaw/fitbit/token.json`) - `FITBIT_TZ` (default: `Asia/Shanghai`) - `FITBIT_SCOPES` (default: `activity sleep heartrate profile weight`) ## Connect (OAuth) - **Phone/remote mode (recommended):** ```bash python3 scripts/fitbit_oauth_login.py ``` - **Desktop loopback mode (optional):** if authorizing on the same machine and `FITBIT_REDIRECT_URI` is loopback: ```bash python3 scripts/fitbit_oauth_login.py --loopback ``` ## Fetch + normalize for a day ```bash python3 scripts/fitbit_fetch_daily.py --date today --out /tmp/fitbit_raw_today.json python3 scripts/fitbit_normalize_daily.py /tmp/fitbit_raw_today.json --out /tmp/fitbit_today.json python3 scripts/fitbit_render.py /tmp/fitbit_today.json --format markdown --channel generic ``` ## References - API/OAuth notes: `references/fitbit_api.md` - Output schema: `references/output_schema.md`

path: references/fitbit_api.md
content: # Fitbit API (quick reference)

OAuth2:
- Authorize: `https://www.fitbit.com/oauth2/authorize`
- Token: `https://api.fitbit.com/oauth2/token` (HTTP Basic auth with client_id:client_secret)

Typical scopes:
- `activity`
- `sleep`
- `heartrate`
- `profile`
- `weight`

Common endpoints used here:
- Daily activity summary:
  - `GET https://api.fitbit.com/1/user/-/activities/date/{YYYY-MM-DD}.json`
- Daily sleep:
  - `GET https://api.fitbit.com/1.2/user/-/sleep/date/{YYYY-MM-DD}.json`


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

`fitbit_normalize_daily.py` outputs:

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


path: scripts/fitbit_fetch_daily.py
content: #!/usr/bin/env python3
"""Fetch Fitbit daily summaries (activity + sleep) for a local day.

Endpoints (typical):
- GET /1/user/-/activities/date/<date>.json
- GET /1.2/user/-/sleep/date/<date>.json

No third-party deps.
"""

from __future__ import annotations

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

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

from fitbit_token import get_access_token


API_BASE = "https://api.fitbit.com"


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 http_get_json(url: str, access_token: str) -> Any:
    req = urllib.request.Request(url, method="GET")
    req.add_header("Authorization", f"Bearer {access_token}")
    req.add_header("Accept", "application/json")
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode("utf-8"))


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("FITBIT_TZ", "Asia/Shanghai"))
    args = ap.parse_args()

    day = resolve_date(args.date, args.tz)

    client_id = must_env("FITBIT_CLIENT_ID")
    client_secret = must_env("FITBIT_CLIENT_SECRET")
    access_token = get_access_token(client_id=client_id, client_secret=client_secret)

    act_url = f"{API_BASE}/1/user/-/activities/date/{day}.json"
    sleep_url = f"{API_BASE}/1.2/user/-/sleep/date/{day}.json"

    bundle: Dict[str, Any] = {
        "requested_date": day,
        "requested_tz": args.tz,
        "fetched_at": datetime.utcnow().isoformat() + "Z",
        "api_base": API_BASE,
        "endpoints": {
            "activity": {"url": act_url, "data": http_get_json(act_url, access_token)},
            "sleep": {"url": sleep_url, "data": http_get_json(sleep_url, access_token)},
        },
    }

    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/fitbit_normalize_daily.py
content: #!/usr/bin/env python3
"""Normalize Fitbit daily raw bundle into Wellness-like fields.

Usage:
  python3 scripts/fitbit_normalize_daily.py raw.json --out fitbit_day.json
"""

from __future__ import annotations

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


def safe_get(d: Any, *keys):
    cur = d
    for k in keys:
        if not isinstance(cur, dict):
            return None
        cur = cur.get(k)
    return cur


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 {}
    act = (ep.get("activity") or {}).get("data")
    slp = (ep.get("sleep") or {}).get("data")

    steps = safe_get(act, "summary", "steps")
    dist = None
    # Fitbit distance is in summary.distances list; pick "total" or first.
    distances = safe_get(act, "summary", "distances")
    if isinstance(distances, list) and distances:
        for x in distances:
            if isinstance(x, dict) and x.get("activity") == "total":
                dist = x.get("distance")
                break
        if dist is None and isinstance(distances[0], dict):
            dist = distances[0].get("distance")

    sleep_minutes = None
    sleep_score = None
    # Sleep response often contains a list of sleeps.
    sleeps = safe_get(slp, "sleep")
    if isinstance(sleeps, list) and sleeps:
        s0 = sleeps[0] if isinstance(sleeps[0], dict) else None
        if isinstance(s0, dict):
            sleep_minutes = s0.get("minutesAsleep")
            # score may be in s0['sleepScore']['overallScore'] depending on API.
            ss = s0.get("sleepScore")
            if isinstance(ss, dict):
                sleep_score = ss.get("overallScore") or ss.get("score")

    out: Dict[str, Any] = {
        "date": date,
        "timezone": tz,
        "generated_at": datetime.utcnow().isoformat() + "Z",
        "sleep": {
            "duration_minutes": sleep_minutes,
            "score": sleep_score,
            "source": "fitbit",
        },
        "activity": {
            "steps": steps,
            "distance_km": dist,
            "source": "fitbit",
        },
        "sources_present": ["fitbit"],
        "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/fitbit_oauth_login.py
content: #!/usr/bin/env python3
"""Interactive Fitbit OAuth login.

Env:
- FITBIT_CLIENT_ID
- FITBIT_CLIENT_SECRET
- FITBIT_REDIRECT_URI
Optional:
- FITBIT_SCOPES (default: activity sleep heartrate profile weight)

Modes:
- default: copy/paste redirect URL or code
- --loopback: listen on loopback redirect URI (127.0.0.1)
"""

from __future__ import annotations

import argparse
import os
import sys
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer

from fitbit_token import AUTH_URL, exchange_code_for_token, save_token, token_path


DEFAULT_SCOPES = "activity sleep heartrate profile weight"


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 parse_code(user_input: str) -> str:
    s = user_input.strip()
    if s.startswith("http://") or s.startswith("https://"):
        u = urllib.parse.urlparse(s)
        q = urllib.parse.parse_qs(u.query)
        code = (q.get("code") or [None])[0]
        if code:
            return code
        raise SystemExit("Could not find ?code= in the pasted redirect URL")
    return s


def listen_for_code(redirect_uri: str, timeout_s: int = 180) -> str:
    u = urllib.parse.urlparse(redirect_uri)
    if u.scheme != "http" or u.hostname not in ("127.0.0.1", "localhost"):
        raise SystemExit("Loopback mode requires FITBIT_REDIRECT_URI like http://127.0.0.1:<port>/<path>")
    if not u.port:
        raise SystemExit("Loopback mode requires an explicit port in FITBIT_REDIRECT_URI")
    path = u.path or "/"

    got = {"code": None}

    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):  # noqa: N802
            parsed = urllib.parse.urlparse(self.path)
            if parsed.path != path:
                self.send_response(404)
                self.end_headers()
                return
            q = urllib.parse.parse_qs(parsed.query)
            code = (q.get("code") or [None])[0]
            if code:
                got["code"] = code
                self.send_response(200)
                self.send_header("Content-Type", "text/plain; charset=utf-8")
                self.end_headers()
                self.wfile.write(b"OK. You can close this tab and return to OpenClaw.\n")
            else:
                self.send_response(400)
                self.end_headers()

        def log_message(self, fmt, *args):
            return

    httpd = HTTPServer((u.hostname, int(u.port)), Handler)
    httpd.timeout = 1

    import time

    deadline = time.time() + timeout_s
    while time.time() < deadline and not got["code"]:
        httpd.handle_request()

    try:
        httpd.server_close()
    except Exception:
        pass

    if not got["code"]:
        raise SystemExit("Timed out waiting for OAuth redirect. Use copy/paste mode instead.")
    return str(got["code"])


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--loopback", action="store_true")
    args = ap.parse_args()

    client_id = must_env("FITBIT_CLIENT_ID")
    client_secret = must_env("FITBIT_CLIENT_SECRET")
    redirect_uri = must_env("FITBIT_REDIRECT_URI")
    scopes = os.environ.get("FITBIT_SCOPES", DEFAULT_SCOPES)

    params = {
        "client_id": client_id,
        "redirect_uri": redirect_uri,
        "response_type": "code",
        "scope": scopes,
    }
    auth_url = AUTH_URL + "?" + urllib.parse.urlencode(params)

    print("Open this URL in a browser and approve access:\n")
    print(auth_url)

    if args.loopback:
        print("\nWaiting for redirect on:")
        print(redirect_uri)
        code = listen_for_code(redirect_uri)
    else:
        print("\nAfter approval, paste either the full redirect URL or just the code:")
        code = parse_code(input("> "))

    tok = exchange_code_for_token(
        code=code,
        redirect_uri=redirect_uri,
        client_id=client_id,
        client_secret=client_secret,
    )

    p = save_token(tok, token_path())
    print(f"\n[OK] Token saved to: {p}")


if __name__ == "__main__":
    main()


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

Usage:
  python3 scripts/fitbit_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 {}
    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"Fitbit — {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 act.get("steps") is not None:
        lines.append(f"{st['bullet']} Steps: {act.get('steps')}")
    if act.get("distance_km") is not None:
        lines.append(f"{st['bullet']} Distance: {act.get('distance_km')} km")

    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()


path: scripts/fitbit_token.py
content: #!/usr/bin/env python3
"""Fitbit token utilities (OAuth2).

No third-party deps.
"""

from __future__ import annotations

import base64
import json
import os
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional


AUTH_URL = "https://www.fitbit.com/oauth2/authorize"
TOKEN_URL = "https://api.fitbit.com/oauth2/token"

DEFAULT_TOKEN_PATH = os.path.expanduser("~/.config/openclaw/fitbit/token.json")


class FitbitAuthError(RuntimeError):
    pass


def token_path() -> str:
    return os.environ.get("FITBIT_TOKEN_PATH", DEFAULT_TOKEN_PATH)


def _mkdirp(p: str) -> None:
    Path(p).parent.mkdir(parents=True, exist_ok=True)


def now_s() -> int:
    return int(time.time())


def load_token(path: Optional[str] = None) -> Dict[str, Any]:
    p = path or token_path()
    with open(p, "r", encoding="utf-8") as f:
        return json.load(f)


def save_token(tok: Dict[str, Any], path: Optional[str] = None) -> str:
    p = path or token_path()
    _mkdirp(p)
    tmp = p + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(tok, f, indent=2, sort_keys=True)
        f.write("\n")
    os.replace(tmp, p)
    try:
        os.chmod(p, 0o600)
    except PermissionError:
        pass
    return p


def is_expired(tok: Dict[str, Any], skew_s: int = 60) -> bool:
    exp = tok.get("expires_at")
    if exp is None:
        return False
    try:
        return now_s() >= int(exp) - skew_s
    except Exception:
        return False


def _basic_auth(client_id: str, client_secret: str) -> str:
    raw = f"{client_id}:{client_secret}".encode("utf-8")
    return base64.b64encode(raw).decode("ascii")


def _post_form(url: str, data: Dict[str, str], headers: Dict[str, str]) -> Dict[str, Any]:
    body = urllib.parse.urlencode(data).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="POST")
    req.add_header("Content-Type", "application/x-www-form-urlencoded")
    for k, v in headers.items():
        req.add_header(k, v)
    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 FitbitAuthError(f"HTTP {e.code} calling {url}: {raw}") from e


def exchange_code_for_token(*, code: str, redirect_uri: str, client_id: str, client_secret: str) -> Dict[str, Any]:
    tok = _post_form(
        TOKEN_URL,
        {
            "code": code,
            "grant_type": "authorization_code",
            "redirect_uri": redirect_uri,
        },
        {"Authorization": f"Basic {_basic_auth(client_id, client_secret)}"},
    )
    _annotate(tok)
    return tok


def refresh_access_token(*, refresh_token: str, client_id: str, client_secret: str) -> Dict[str, Any]:
    tok = _post_form(
        TOKEN_URL,
        {
            "refresh_token": refresh_token,
            "grant_type": "refresh_token",
        },
        {"Authorization": f"Basic {_basic_auth(client_id, client_secret)}"},
    )
    _annotate(tok)
    return tok


def _annotate(tok: Dict[str, Any]) -> None:
    exp_in = tok.get("expires_in")
    if exp_in is not None:
        try:
            tok["expires_at"] = now_s() + int(exp_in)
        except Exception:
            pass


def get_access_token(*, client_id: str, client_secret: str, token_file: Optional[str] = None) -> str:
    p = token_file or token_path()
    tok = load_token(p)
    access = tok.get("access_token")
    if not access:
        raise FitbitAuthError(f"No access_token found in {p}. Run fitbit_oauth_login.py")
    if not is_expired(tok):
        return str(access)

    rt = tok.get("refresh_token")
    if not rt:
        raise FitbitAuthError("Token expired and no refresh_token available; re-run OAuth login")

    new_tok = refresh_access_token(refresh_token=str(rt), client_id=client_id, client_secret=client_secret)
    if not new_tok.get("refresh_token"):
        new_tok["refresh_token"] = rt
    save_token(new_tok, p)
    return str(new_tok["access_token"])


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