feat(whoop): add Whoop fitness plugin for data access
- Introduced a new Whoop fitness plugin that allows querying of recovery scores, sleep analysis, cycle data, and workout tracking. - Implemented OAuth 2.0 authentication flow for secure access to Whoop API. - Added comprehensive documentation and example configurations for plugin setup. - Included unit tests for API client methods to ensure reliability and correctness. - Updated pnpm-lock.yaml to reflect new optional dependencies for the plugin.
This commit is contained in:
parent
20f6a5546f
commit
e3bb3790b7
65
extensions/whoop/README.md
Normal file
65
extensions/whoop/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Whoop (plugin)
|
||||
|
||||
Adds an **optional** agent tool `get_whoop_data` for querying Whoop fitness data including recovery scores, sleep analysis, strain/cycle tracking, and workout metrics.
|
||||
|
||||
## Enable
|
||||
|
||||
1) Configure the plugin with your Whoop OAuth credentials:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"whoop": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2) Register redirect URI at [developer.whoop.com](https://developer.whoop.com/):
|
||||
```
|
||||
http://localhost:8086/oauth2callback
|
||||
```
|
||||
|
||||
3) Complete OAuth flow to authenticate with Whoop.
|
||||
|
||||
4) Allowlist the tool (it is registered with `optional: true`):
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": { "allow": ["get_whoop_data"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Types
|
||||
|
||||
The tool provides access to:
|
||||
- **Recovery**: HRV, resting heart rate, SpO2, skin temperature
|
||||
- **Sleep**: Sleep stages, performance, efficiency, respiratory rate
|
||||
- **Cycles**: Daily strain, average/max heart rate
|
||||
- **Workouts**: Activity strain, heart rate zones, distance, calories
|
||||
|
||||
## Query Types
|
||||
|
||||
- `latest` - Get most recent single record
|
||||
- `recent` - Get last N records (default: 7, max: 25)
|
||||
- `by_id` - Get specific record by ID
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses Whoop API v2 endpoints
|
||||
- Automatic token refresh when expired
|
||||
- Tokens stored securely via Clawdbot's credential system
|
||||
31
extensions/whoop/clawdbot.plugin.json
Normal file
31
extensions/whoop/clawdbot.plugin.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"id": "whoop",
|
||||
"name": "Whoop Fitness",
|
||||
"description": "Query Whoop fitness data including recovery scores, sleep analysis, strain/cycle data, and workout tracking",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"clientId": {
|
||||
"type": "string",
|
||||
"description": "Whoop API OAuth Client ID"
|
||||
},
|
||||
"clientSecret": {
|
||||
"type": "string",
|
||||
"description": "Whoop API OAuth Client Secret"
|
||||
}
|
||||
},
|
||||
"required": ["clientId", "clientSecret"]
|
||||
},
|
||||
"uiHints": {
|
||||
"clientId": {
|
||||
"label": "Whoop Client ID",
|
||||
"placeholder": "Your Client ID from developer.whoop.com"
|
||||
},
|
||||
"clientSecret": {
|
||||
"label": "Whoop Client Secret",
|
||||
"sensitive": true,
|
||||
"placeholder": "Your Client Secret from developer.whoop.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
100
extensions/whoop/index.ts
Normal file
100
extensions/whoop/index.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Whoop Fitness Plugin for Clawdbot
|
||||
* Provides access to Whoop recovery, sleep, cycle, and workout data
|
||||
*/
|
||||
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { createWhoopTool } from "./src/whoop-tool.js";
|
||||
import { loginWhoopOAuth } from "./src/oauth.js";
|
||||
|
||||
const PROVIDER_ID = "whoop";
|
||||
const PROVIDER_LABEL = "Whoop Fitness";
|
||||
|
||||
export default function register(api: ClawdbotPluginApi) {
|
||||
api.logger.info("Registering Whoop fitness plugin");
|
||||
|
||||
// Register OAuth provider
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/plugins/whoop",
|
||||
envVars: ["WHOOP_CLIENT_ID", "WHOOP_CLIENT_SECRET"],
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
label: "Whoop OAuth",
|
||||
hint: "OAuth 2.0 with localhost callback",
|
||||
kind: "oauth",
|
||||
run: async (ctx) => {
|
||||
const spin = ctx.prompter.progress("Starting Whoop OAuth…");
|
||||
|
||||
// Get client credentials from config
|
||||
const config = api.config?.plugins?.entries?.whoop?.config as
|
||||
| { clientId?: string; clientSecret?: string }
|
||||
| undefined;
|
||||
|
||||
const clientId =
|
||||
config?.clientId || process.env.WHOOP_CLIENT_ID || process.env.CLAWDBOT_WHOOP_CLIENT_ID;
|
||||
const clientSecret =
|
||||
config?.clientSecret ||
|
||||
process.env.WHOOP_CLIENT_SECRET ||
|
||||
process.env.CLAWDBOT_WHOOP_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
spin.stop("Configuration missing");
|
||||
throw new Error(
|
||||
"Whoop Client ID and Client Secret required. Add them to your config or set WHOOP_CLIENT_ID/WHOOP_CLIENT_SECRET environment variables.",
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginWhoopOAuth({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
log: (msg) => ctx.runtime.log(msg),
|
||||
note: ctx.prompter.note,
|
||||
prompt: async (message) => String(await ctx.prompter.text({ message })),
|
||||
progress: spin,
|
||||
clientId,
|
||||
clientSecret,
|
||||
});
|
||||
|
||||
spin.stop("Whoop OAuth complete");
|
||||
|
||||
const profileId = `whoop:${result.userId ?? "default"}`;
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: PROVIDER_ID,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
userId: result.userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
notes: [
|
||||
"Your Whoop account is now connected!",
|
||||
"The agent tool 'get_whoop_data' will be available once allowlisted.",
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
spin.stop("Whoop OAuth failed");
|
||||
await ctx.prompter.note(
|
||||
"Check that your redirect URI (http://localhost:8086/oauth2callback) is registered in your Whoop app settings at developer.whoop.com",
|
||||
"OAuth help",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Register tool
|
||||
api.registerTool(createWhoopTool(api), { optional: true });
|
||||
}
|
||||
11
extensions/whoop/package.json
Normal file
11
extensions/whoop/package.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/whoop",
|
||||
"version": "2026.1.25",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Whoop fitness plugin - access recovery, sleep, cycles, and workout data",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
}
|
||||
320
extensions/whoop/src/oauth.ts
Normal file
320
extensions/whoop/src/oauth.ts
Normal file
@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Whoop OAuth 2.0 helper
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth";
|
||||
const TOKEN_URL = "https://api.prod.whoop.com/oauth/oauth2/token";
|
||||
const REDIRECT_URI = "http://localhost:8086/oauth2callback";
|
||||
const SCOPES = [
|
||||
"read:recovery",
|
||||
"read:sleep",
|
||||
"read:cycles",
|
||||
"read:workout",
|
||||
"offline", // Required for refresh tokens
|
||||
];
|
||||
|
||||
export interface WhoopOAuthCredentials {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface WhoopOAuthContext {
|
||||
isRemote: boolean;
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
log: (msg: string) => void;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
prompt: (message: string) => Promise<string>;
|
||||
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
function buildAuthUrl(clientId: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES.join(" "),
|
||||
state,
|
||||
});
|
||||
return `${AUTH_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseCallbackInput(
|
||||
input: string,
|
||||
expectedState: string,
|
||||
): { code: string; state: string } | { error: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { error: "No input provided" };
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state") ?? expectedState;
|
||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
||||
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||
return { code, state };
|
||||
} catch {
|
||||
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
|
||||
return { code: trimmed, state: expectedState };
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLocalCallback(params: {
|
||||
expectedState: string;
|
||||
timeoutMs: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}): Promise<{ code: string; state: string }> {
|
||||
const port = 8086;
|
||||
const hostname = "localhost";
|
||||
const expectedPath = "/oauth2callback";
|
||||
|
||||
return new Promise<{ code: string; state: string }>((resolve, reject) => {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
|
||||
if (requestUrl.pathname !== expectedPath) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const error = requestUrl.searchParams.get("error");
|
||||
const code = requestUrl.searchParams.get("code")?.trim();
|
||||
const state = requestUrl.searchParams.get("state")?.trim();
|
||||
|
||||
if (error) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end(`Authentication failed: ${error}`);
|
||||
finish(new Error(`OAuth error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Missing code or state");
|
||||
finish(new Error("Missing OAuth code or state"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== params.expectedState) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain");
|
||||
res.end("Invalid state");
|
||||
finish(new Error("OAuth state mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(
|
||||
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
|
||||
"<body><h2>Whoop OAuth complete</h2>" +
|
||||
"<p>You can close this window and return to Clawdbot.</p></body></html>",
|
||||
);
|
||||
|
||||
finish(undefined, { code, state });
|
||||
} catch (err) {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
|
||||
}
|
||||
});
|
||||
|
||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (result) {
|
||||
resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
server.once("error", (err) => {
|
||||
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
|
||||
});
|
||||
|
||||
server.listen(port, hostname, () => {
|
||||
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
finish(new Error("OAuth callback timeout"));
|
||||
}, params.timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<WhoopOAuthCredentials> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
if (!data.refresh_token) {
|
||||
throw new Error("No refresh token received. Ensure the 'offline' scope is requested.");
|
||||
}
|
||||
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; // 5min buffer
|
||||
|
||||
return {
|
||||
access: data.access_token,
|
||||
refresh: data.refresh_token,
|
||||
expires: expiresAt,
|
||||
userId: data.user_id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<WhoopOAuthCredentials> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token refresh failed: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user_id?: string;
|
||||
};
|
||||
|
||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
||||
|
||||
return {
|
||||
access: data.access_token,
|
||||
refresh: data.refresh_token,
|
||||
expires: expiresAt,
|
||||
userId: data.user_id,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
|
||||
return isRemote;
|
||||
}
|
||||
|
||||
export async function loginWhoopOAuth(ctx: WhoopOAuthContext): Promise<WhoopOAuthCredentials> {
|
||||
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
|
||||
|
||||
await ctx.note(
|
||||
needsManual
|
||||
? [
|
||||
"You are running in a remote/VPS environment.",
|
||||
"A URL will be shown for you to open in your LOCAL browser.",
|
||||
"After signing in, copy the redirect URL and paste it back here.",
|
||||
].join("\n")
|
||||
: [
|
||||
"Browser will open for Whoop authentication.",
|
||||
"Sign in with your Whoop account.",
|
||||
"The callback will be captured automatically on localhost:8086.",
|
||||
].join("\n"),
|
||||
"Whoop OAuth",
|
||||
);
|
||||
|
||||
const state = generateState();
|
||||
const authUrl = buildAuthUrl(ctx.clientId, state);
|
||||
|
||||
if (needsManual) {
|
||||
ctx.progress.update("OAuth URL ready");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
ctx.progress.update("Waiting for you to paste the callback URL...");
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, state);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== state) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, ctx.clientId, ctx.clientSecret);
|
||||
}
|
||||
|
||||
ctx.progress.update("Complete sign-in in browser...");
|
||||
try {
|
||||
await ctx.openUrl(authUrl);
|
||||
} catch {
|
||||
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { code } = await waitForLocalCallback({
|
||||
expectedState: state,
|
||||
timeoutMs: 5 * 60 * 1000,
|
||||
onProgress: (msg) => ctx.progress.update(msg),
|
||||
});
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return await exchangeCodeForTokens(code, ctx.clientId, ctx.clientSecret);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes("EADDRINUSE") || err.message.includes("port") || err.message.includes("listen"))
|
||||
) {
|
||||
ctx.progress.update("Local callback server failed. Switching to manual mode...");
|
||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||
const parsed = parseCallbackInput(callbackInput, state);
|
||||
if ("error" in parsed) throw new Error(parsed.error);
|
||||
if (parsed.state !== state) {
|
||||
throw new Error("OAuth state mismatch - please try again");
|
||||
}
|
||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||
return exchangeCodeForTokens(parsed.code, ctx.clientId, ctx.clientSecret);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
244
extensions/whoop/src/whoop-api.test.ts
Normal file
244
extensions/whoop/src/whoop-api.test.ts
Normal file
@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Tests for Whoop API Client
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { WhoopApiClient } from "./whoop-api.js";
|
||||
|
||||
describe("WhoopApiClient", () => {
|
||||
let client: WhoopApiClient;
|
||||
const mockAccessToken = "test-token-123";
|
||||
|
||||
beforeEach(() => {
|
||||
client = new WhoopApiClient({ accessToken: mockAccessToken });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Recovery", () => {
|
||||
it("should fetch recovery data with limit", async () => {
|
||||
const mockResponse = {
|
||||
records: [
|
||||
{
|
||||
cycle_id: 12345,
|
||||
sleep_id: 67890,
|
||||
user_id: 1,
|
||||
created_at: "2026-01-26T08:00:00.000Z",
|
||||
updated_at: "2026-01-26T08:00:00.000Z",
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
user_calibrating: false,
|
||||
recovery_score: 85,
|
||||
resting_heart_rate: 52,
|
||||
hrv_rmssd_milli: 65,
|
||||
spo2_percentage: 98.5,
|
||||
skin_temp_celsius: 33.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await client.getRecovery(1);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/recovery?limit=1"),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: `Bearer ${mockAccessToken}`,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.records[0].score.recovery_score).toBe(85);
|
||||
});
|
||||
|
||||
it("should fetch current recovery", async () => {
|
||||
const mockResponse = {
|
||||
records: [
|
||||
{
|
||||
cycle_id: 12345,
|
||||
sleep_id: 67890,
|
||||
user_id: 1,
|
||||
created_at: "2026-01-26T08:00:00.000Z",
|
||||
updated_at: "2026-01-26T08:00:00.000Z",
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
user_calibrating: false,
|
||||
recovery_score: 75,
|
||||
resting_heart_rate: 54,
|
||||
hrv_rmssd_milli: 55,
|
||||
spo2_percentage: 97.8,
|
||||
skin_temp_celsius: 33.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await client.getCurrentRecovery();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.score.recovery_score).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sleep", () => {
|
||||
it("should fetch sleep data", async () => {
|
||||
const mockResponse = {
|
||||
records: [
|
||||
{
|
||||
id: 67890,
|
||||
user_id: 1,
|
||||
created_at: "2026-01-26T07:00:00.000Z",
|
||||
updated_at: "2026-01-26T07:00:00.000Z",
|
||||
start: "2026-01-25T23:00:00.000Z",
|
||||
end: "2026-01-26T07:00:00.000Z",
|
||||
timezone_offset: "-08:00",
|
||||
nap: false,
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
stage_summary: {
|
||||
total_in_bed_time_milli: 28800000,
|
||||
total_awake_time_milli: 1800000,
|
||||
total_no_data_time_milli: 0,
|
||||
total_light_sleep_time_milli: 12600000,
|
||||
total_slow_wave_sleep_time_milli: 7200000,
|
||||
total_rem_sleep_time_milli: 7200000,
|
||||
sleep_cycle_count: 5,
|
||||
disturbance_count: 3,
|
||||
},
|
||||
sleep_needed: {
|
||||
baseline_milli: 28800000,
|
||||
need_from_sleep_debt_milli: 0,
|
||||
need_from_recent_strain_milli: 0,
|
||||
need_from_recent_nap_milli: 0,
|
||||
},
|
||||
respiratory_rate: 14.5,
|
||||
sleep_performance_percentage: 95,
|
||||
sleep_consistency_percentage: 88,
|
||||
sleep_efficiency_percentage: 93,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await client.getSleep(1);
|
||||
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.records[0].score.sleep_performance_percentage).toBe(95);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cycles", () => {
|
||||
it("should fetch cycle data", async () => {
|
||||
const mockResponse = {
|
||||
records: [
|
||||
{
|
||||
id: 12345,
|
||||
user_id: 1,
|
||||
created_at: "2026-01-26T08:00:00.000Z",
|
||||
updated_at: "2026-01-26T08:00:00.000Z",
|
||||
start: "2026-01-25T08:00:00.000Z",
|
||||
end: "2026-01-26T08:00:00.000Z",
|
||||
timezone_offset: "-08:00",
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
strain: 12.5,
|
||||
kilojoule: 8500,
|
||||
average_heart_rate: 65,
|
||||
max_heart_rate: 145,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await client.getCycles(1);
|
||||
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.records[0].score.strain).toBe(12.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workouts", () => {
|
||||
it("should fetch workout data", async () => {
|
||||
const mockResponse = {
|
||||
records: [
|
||||
{
|
||||
id: 98765,
|
||||
user_id: 1,
|
||||
created_at: "2026-01-26T10:00:00.000Z",
|
||||
updated_at: "2026-01-26T10:00:00.000Z",
|
||||
start: "2026-01-26T09:00:00.000Z",
|
||||
end: "2026-01-26T10:00:00.000Z",
|
||||
timezone_offset: "-08:00",
|
||||
sport_id: 1,
|
||||
score_state: "SCORED",
|
||||
score: {
|
||||
strain: 8.5,
|
||||
average_heart_rate: 135,
|
||||
max_heart_rate: 165,
|
||||
kilojoule: 2500,
|
||||
percent_recorded: 100,
|
||||
distance_meter: 5000,
|
||||
altitude_gain_meter: 50,
|
||||
altitude_change_meter: 50,
|
||||
zone_duration: {
|
||||
zone_zero_milli: 0,
|
||||
zone_one_milli: 300000,
|
||||
zone_two_milli: 900000,
|
||||
zone_three_milli: 1200000,
|
||||
zone_four_milli: 1200000,
|
||||
zone_five_milli: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
|
||||
const result = await client.getWorkouts(1);
|
||||
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.records[0].score.strain).toBe(8.5);
|
||||
expect(result.records[0].score.distance_meter).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should handle API errors", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
text: async () => "Invalid access token",
|
||||
});
|
||||
|
||||
await expect(client.getCurrentRecovery()).rejects.toThrow(
|
||||
"Whoop API error (401): Invalid access token"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
242
extensions/whoop/src/whoop-api.ts
Normal file
242
extensions/whoop/src/whoop-api.ts
Normal file
@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Whoop API Client
|
||||
* Implements methods for recovery, sleep, cycles, and workout data
|
||||
*/
|
||||
|
||||
const WHOOP_API_BASE = 'https://api.prod.whoop.com/developer';
|
||||
|
||||
export interface WhoopApiConfig {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface WhoopRecovery {
|
||||
cycle_id: number;
|
||||
sleep_id: string; // v2 uses UUID for sleep_id
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
score_state: string;
|
||||
score: {
|
||||
user_calibrating: boolean;
|
||||
recovery_score: number;
|
||||
resting_heart_rate: number;
|
||||
hrv_rmssd_milli: number;
|
||||
spo2_percentage: number;
|
||||
skin_temp_celsius: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WhoopSleep {
|
||||
id: string; // v2 uses UUID
|
||||
cycle_id: number;
|
||||
v1_id?: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
start: string;
|
||||
end: string;
|
||||
timezone_offset: string;
|
||||
nap: boolean;
|
||||
score_state: string;
|
||||
score: {
|
||||
stage_summary: {
|
||||
total_in_bed_time_milli: number;
|
||||
total_awake_time_milli: number;
|
||||
total_no_data_time_milli: number;
|
||||
total_light_sleep_time_milli: number;
|
||||
total_slow_wave_sleep_time_milli: number;
|
||||
total_rem_sleep_time_milli: number;
|
||||
sleep_cycle_count: number;
|
||||
disturbance_count: number;
|
||||
};
|
||||
sleep_needed: {
|
||||
baseline_milli: number;
|
||||
need_from_sleep_debt_milli: number;
|
||||
need_from_recent_strain_milli: number;
|
||||
need_from_recent_nap_milli: number;
|
||||
};
|
||||
respiratory_rate: number;
|
||||
sleep_performance_percentage: number;
|
||||
sleep_consistency_percentage: number;
|
||||
sleep_efficiency_percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WhoopCycle {
|
||||
id: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
start: string;
|
||||
end: string;
|
||||
timezone_offset: string;
|
||||
score_state: string;
|
||||
score: {
|
||||
strain: number;
|
||||
kilojoule: number;
|
||||
average_heart_rate: number;
|
||||
max_heart_rate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WhoopWorkout {
|
||||
id: string; // v2 uses UUID
|
||||
v1_id?: number;
|
||||
user_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
start: string;
|
||||
end: string;
|
||||
timezone_offset: string;
|
||||
sport_id: number;
|
||||
sport_name?: string;
|
||||
score_state: string;
|
||||
score: {
|
||||
strain: number;
|
||||
average_heart_rate: number;
|
||||
max_heart_rate: number;
|
||||
kilojoule: number;
|
||||
percent_recorded: number;
|
||||
distance_meter: number;
|
||||
altitude_gain_meter: number;
|
||||
altitude_change_meter: number;
|
||||
zone_durations: {
|
||||
zone_zero_milli: number;
|
||||
zone_one_milli: number;
|
||||
zone_two_milli: number;
|
||||
zone_three_milli: number;
|
||||
zone_four_milli: number;
|
||||
zone_five_milli: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface WhoopListResponse<T> {
|
||||
records: T[];
|
||||
next_token?: string;
|
||||
}
|
||||
|
||||
export class WhoopApiClient {
|
||||
private config: WhoopApiConfig;
|
||||
|
||||
constructor(config: WhoopApiConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string): Promise<T> {
|
||||
const url = `${WHOOP_API_BASE}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Whoop API error (${response.status}): ${errorText || response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery data
|
||||
* @param limit Number of records to return (default: 10)
|
||||
*/
|
||||
async getRecovery(limit = 10): Promise<WhoopListResponse<WhoopRecovery>> {
|
||||
const params = new URLSearchParams({ limit: limit.toString() });
|
||||
return this.request<WhoopListResponse<WhoopRecovery>>(`/v2/recovery?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current/latest recovery score
|
||||
*/
|
||||
async getCurrentRecovery(): Promise<WhoopRecovery | null> {
|
||||
const response = await this.getRecovery(1);
|
||||
return response.records[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recovery by cycle ID
|
||||
*/
|
||||
async getRecoveryById(cycleId: number): Promise<WhoopRecovery> {
|
||||
return this.request<WhoopRecovery>(`/v2/cycle/${cycleId}/recovery`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sleep data
|
||||
* @param limit Number of records to return (default: 10)
|
||||
*/
|
||||
async getSleep(limit = 10): Promise<WhoopListResponse<WhoopSleep>> {
|
||||
const params = new URLSearchParams({ limit: limit.toString() });
|
||||
return this.request<WhoopListResponse<WhoopSleep>>(`/v2/activity/sleep?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest sleep session
|
||||
*/
|
||||
async getLatestSleep(): Promise<WhoopSleep | null> {
|
||||
const response = await this.getSleep(1);
|
||||
return response.records[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sleep by ID (v2 uses UUID)
|
||||
*/
|
||||
async getSleepById(sleepId: string): Promise<WhoopSleep> {
|
||||
return this.request<WhoopSleep>(`/v2/activity/sleep/${sleepId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cycle data (strain)
|
||||
* @param limit Number of records to return (default: 10)
|
||||
*/
|
||||
async getCycles(limit = 10): Promise<WhoopListResponse<WhoopCycle>> {
|
||||
const params = new URLSearchParams({ limit: limit.toString() });
|
||||
return this.request<WhoopListResponse<WhoopCycle>>(`/v2/cycle?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest cycle
|
||||
*/
|
||||
async getLatestCycle(): Promise<WhoopCycle | null> {
|
||||
const response = await this.getCycles(1);
|
||||
return response.records[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cycle by ID
|
||||
*/
|
||||
async getCycleById(cycleId: number): Promise<WhoopCycle> {
|
||||
return this.request<WhoopCycle>(`/v2/cycle/${cycleId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workout data
|
||||
* @param limit Number of records to return (default: 10)
|
||||
*/
|
||||
async getWorkouts(limit = 10): Promise<WhoopListResponse<WhoopWorkout>> {
|
||||
const params = new URLSearchParams({ limit: limit.toString() });
|
||||
return this.request<WhoopListResponse<WhoopWorkout>>(`/v2/activity/workout?${params}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest workout
|
||||
*/
|
||||
async getLatestWorkout(): Promise<WhoopWorkout | null> {
|
||||
const response = await this.getWorkouts(1);
|
||||
return response.records[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workout by ID (v2 uses UUID)
|
||||
*/
|
||||
async getWorkoutById(workoutId: string): Promise<WhoopWorkout> {
|
||||
return this.request<WhoopWorkout>(`/v2/activity/workout/${workoutId}`);
|
||||
}
|
||||
}
|
||||
215
extensions/whoop/src/whoop-tool.ts
Normal file
215
extensions/whoop/src/whoop-tool.ts
Normal file
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Whoop Tool for Clawdbot
|
||||
* Provides agent access to Whoop fitness data
|
||||
*/
|
||||
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import type { Tool } from "@anthropic-ai/sdk/resources/messages.mjs";
|
||||
import { WhoopApiClient } from "./whoop-api.js";
|
||||
import { refreshAccessToken } from "./oauth.js";
|
||||
|
||||
interface WhoopPluginConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
interface WhoopCredential {
|
||||
type: "oauth";
|
||||
provider: string;
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
async function getAccessToken(api: ClawdbotPluginApi, config: WhoopPluginConfig): Promise<string> {
|
||||
const credentials = api.runtime?.credentials;
|
||||
if (!credentials) {
|
||||
throw new Error("Credentials runtime not available");
|
||||
}
|
||||
|
||||
// Find Whoop credential
|
||||
const creds = await credentials.list();
|
||||
const whoopCred = creds.find((c) => c.provider === "whoop" && c.type === "oauth") as
|
||||
| WhoopCredential
|
||||
| undefined;
|
||||
|
||||
if (!whoopCred) {
|
||||
throw new Error(
|
||||
"No Whoop credentials found. Please run: clawdbot login whoop (or authenticate via the UI)",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if token needs refresh
|
||||
if (Date.now() >= whoopCred.expires) {
|
||||
api.logger.info("Refreshing Whoop access token");
|
||||
const refreshed = await refreshAccessToken(whoopCred.refresh, config.clientId, config.clientSecret);
|
||||
|
||||
// Update stored credentials
|
||||
await credentials.update(whoopCred.profileId || "whoop:default", {
|
||||
type: "oauth",
|
||||
provider: "whoop",
|
||||
access: refreshed.access,
|
||||
refresh: refreshed.refresh,
|
||||
expires: refreshed.expires,
|
||||
userId: refreshed.userId,
|
||||
});
|
||||
|
||||
return refreshed.access;
|
||||
}
|
||||
|
||||
return whoopCred.access;
|
||||
}
|
||||
|
||||
export function createWhoopTool(api: ClawdbotPluginApi): Tool {
|
||||
return {
|
||||
name: "get_whoop_data",
|
||||
description: `Query Whoop fitness data including:
|
||||
- Recovery scores (HRV, RHR, SpO2, skin temp)
|
||||
- Sleep analysis (stages, quality, sleep debt)
|
||||
- Cycle/strain data (daily strain, heart rate)
|
||||
- Workout tracking (activities, heart rate zones, calories)
|
||||
|
||||
Use this tool to answer questions about fitness metrics, sleep quality, recovery readiness, and workout performance.`,
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
data_type: {
|
||||
type: "string",
|
||||
enum: ["recovery", "sleep", "cycle", "workout"],
|
||||
description: "The type of Whoop data to retrieve",
|
||||
},
|
||||
query: {
|
||||
type: "string",
|
||||
enum: ["latest", "recent", "by_id"],
|
||||
description: "How to query the data: 'latest' for most recent single record, 'recent' for last 7 days, 'by_id' for specific record",
|
||||
},
|
||||
id: {
|
||||
type: "number",
|
||||
description: "The ID of the specific record (required when query is 'by_id')",
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
description: "Number of records to return for 'recent' query (default: 7, max: 25)",
|
||||
},
|
||||
},
|
||||
required: ["data_type", "query"],
|
||||
},
|
||||
async handler(input: unknown) {
|
||||
const params = input as {
|
||||
data_type: "recovery" | "sleep" | "cycle" | "workout";
|
||||
query: "latest" | "recent" | "by_id";
|
||||
id?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
// Get config
|
||||
const config = api.config?.plugins?.entries?.whoop?.config as WhoopPluginConfig | undefined;
|
||||
|
||||
if (!config?.clientId || !config?.clientSecret) {
|
||||
return {
|
||||
error:
|
||||
"Whoop plugin not configured. Please add your Client ID and Client Secret to the configuration.",
|
||||
};
|
||||
}
|
||||
|
||||
let accessToken: string;
|
||||
try {
|
||||
accessToken = await getAccessToken(api, config);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return { error: errorMessage };
|
||||
}
|
||||
|
||||
const client = new WhoopApiClient({ accessToken });
|
||||
|
||||
try {
|
||||
// Handle query by ID
|
||||
if (params.query === "by_id") {
|
||||
if (!params.id) {
|
||||
return { error: "ID is required when query is 'by_id'" };
|
||||
}
|
||||
|
||||
let result;
|
||||
switch (params.data_type) {
|
||||
case "recovery":
|
||||
result = await client.getRecoveryById(params.id);
|
||||
break;
|
||||
case "sleep":
|
||||
result = await client.getSleepById(params.id);
|
||||
break;
|
||||
case "cycle":
|
||||
result = await client.getCycleById(params.id);
|
||||
break;
|
||||
case "workout":
|
||||
result = await client.getWorkoutById(params.id);
|
||||
break;
|
||||
}
|
||||
|
||||
return { data: result };
|
||||
}
|
||||
|
||||
// Handle latest query
|
||||
if (params.query === "latest") {
|
||||
let result;
|
||||
switch (params.data_type) {
|
||||
case "recovery":
|
||||
result = await client.getCurrentRecovery();
|
||||
break;
|
||||
case "sleep":
|
||||
result = await client.getLatestSleep();
|
||||
break;
|
||||
case "cycle":
|
||||
result = await client.getLatestCycle();
|
||||
break;
|
||||
case "workout":
|
||||
result = await client.getLatestWorkout();
|
||||
break;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return { message: `No ${params.data_type} data found` };
|
||||
}
|
||||
|
||||
return { data: result };
|
||||
}
|
||||
|
||||
// Handle recent query
|
||||
if (params.query === "recent") {
|
||||
const limit = Math.min(params.limit || 7, 25);
|
||||
let result;
|
||||
|
||||
switch (params.data_type) {
|
||||
case "recovery":
|
||||
result = await client.getRecovery(limit);
|
||||
break;
|
||||
case "sleep":
|
||||
result = await client.getSleep(limit);
|
||||
break;
|
||||
case "cycle":
|
||||
result = await client.getCycles(limit);
|
||||
break;
|
||||
case "workout":
|
||||
result = await client.getWorkouts(limit);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.records,
|
||||
count: result.records.length,
|
||||
next_token: result.next_token,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: "Invalid query type" };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
api.logger.error("Whoop API error:", { error: errorMessage });
|
||||
|
||||
return {
|
||||
error: `Failed to fetch Whoop data: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -172,13 +172,6 @@ importers:
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas':
|
||||
specifier: ^0.1.88
|
||||
version: 0.1.88
|
||||
node-llama-cpp:
|
||||
specifier: 3.15.0
|
||||
version: 3.15.0(typescript@5.9.3)
|
||||
devDependencies:
|
||||
'@grammyjs/types':
|
||||
specifier: ^3.23.0
|
||||
@ -261,6 +254,13 @@ importers:
|
||||
wireit:
|
||||
specifier: ^0.14.12
|
||||
version: 0.14.12
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas':
|
||||
specifier: ^0.1.88
|
||||
version: 0.1.88
|
||||
node-llama-cpp:
|
||||
specifier: 3.15.0
|
||||
version: 3.15.0(typescript@5.9.3)
|
||||
|
||||
extensions/bluebubbles: {}
|
||||
|
||||
@ -457,6 +457,8 @@ importers:
|
||||
|
||||
extensions/whatsapp: {}
|
||||
|
||||
extensions/whoop: {}
|
||||
|
||||
extensions/zalo:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user