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:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 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:
|
devDependencies:
|
||||||
'@grammyjs/types':
|
'@grammyjs/types':
|
||||||
specifier: ^3.23.0
|
specifier: ^3.23.0
|
||||||
@ -261,6 +254,13 @@ importers:
|
|||||||
wireit:
|
wireit:
|
||||||
specifier: ^0.14.12
|
specifier: ^0.14.12
|
||||||
version: 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: {}
|
extensions/bluebubbles: {}
|
||||||
|
|
||||||
@ -457,6 +457,8 @@ importers:
|
|||||||
|
|
||||||
extensions/whatsapp: {}
|
extensions/whatsapp: {}
|
||||||
|
|
||||||
|
extensions/whoop: {}
|
||||||
|
|
||||||
extensions/zalo:
|
extensions/zalo:
|
||||||
dependencies:
|
dependencies:
|
||||||
clawdbot:
|
clawdbot:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user