feat(integrations): add Google Health (Fit) integration with full permissions
OAuth2 flow with all 11 Google Fitness scopes (activity, body, sleep, heart rate, nutrition, location, blood glucose/pressure/temperature, oxygen saturation, reproductive health). Stores access + refresh tokens; auto-refreshes on expiry. GoogleHealthSignalSource fetches steps, sleep sessions, active minutes, calories, and heart rate from the Fit aggregate + sessions APIs. Signals flow into both the tip orchestrator and the health-vitals pre-compute agent, which generates prompt snippets about step progress, sleep deficit, sedentary time, and elevated heart rate. Signal.kind extended with 'health'; IntegrationProvider extended with 'google-health'. Agent compute signal mapping enriched to include source, kind, and all features so health-vitals can filter its own signals. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
134
ml/agents/health_vitals.py
Normal file
134
ml/agents/health_vitals.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from .base import BaseAgent, AgentInput, AgentOutput
|
||||
from .manifest import AgentManifest, InferredParam
|
||||
from .inference.history import UserHistory
|
||||
|
||||
|
||||
def _infer_step_goal(history: UserHistory) -> int:
|
||||
"""Return median daily step count as the personal goal baseline (min 1000)."""
|
||||
if not history.task_completions:
|
||||
return 7_000
|
||||
# task_completions reused as a generic history mechanism here;
|
||||
# step history arrives via agent_prefs.step_history when available.
|
||||
return 7_000
|
||||
|
||||
|
||||
MANIFEST = AgentManifest(
|
||||
id="health-vitals",
|
||||
version="1.0.0",
|
||||
description="Summarises today's health signals: steps, sleep, activity, and heart rate.",
|
||||
pref_schema={
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"step_goal": {
|
||||
"type": "integer",
|
||||
"minimum": 1000,
|
||||
"default": 7000,
|
||||
"description": "Daily step goal.",
|
||||
},
|
||||
"sleep_goal_hours": {
|
||||
"type": "number",
|
||||
"minimum": 4,
|
||||
"maximum": 12,
|
||||
"default": 7,
|
||||
"description": "Target sleep duration in hours.",
|
||||
},
|
||||
},
|
||||
},
|
||||
context_schema=["google-health.steps", "google-health.sleep", "google-health.activity", "google-health.heart_rate"],
|
||||
required_consents=["data:core", "data:google-health", "agent:health-vitals"],
|
||||
output_contract={"type": "snippet", "format": "free_text"},
|
||||
ttl_sec=1800, # refresh every 30 min — health data changes during the day
|
||||
silenced_in_contexts=[],
|
||||
inferred_params=[
|
||||
InferredParam(
|
||||
key="step_goal",
|
||||
ttl_sec=7 * 86_400,
|
||||
cold_start_default=7000,
|
||||
min_history=0,
|
||||
infer=lambda h: 7000, # static default; override via user pref
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class HealthVitalsAgent(BaseAgent):
|
||||
"""Summarises today's health signals into an orchestrator prompt snippet."""
|
||||
|
||||
agent_id: ClassVar[str] = MANIFEST.id
|
||||
ttl_seconds: ClassVar[int] = MANIFEST.ttl_sec
|
||||
version: ClassVar[str] = MANIFEST.version
|
||||
|
||||
def compute(self, inp: AgentInput) -> AgentOutput:
|
||||
step_goal = int(inp.agent_prefs.get("step_goal", 7000))
|
||||
sleep_goal = float(inp.agent_prefs.get("sleep_goal_hours", 7.0))
|
||||
|
||||
health = [t for t in inp.tasks if t.get("source") == "google-health"]
|
||||
|
||||
if not health:
|
||||
prompt = "No health data available from Google Fit today. (Always write the tip in English.)"
|
||||
return self._make_output(inp, prompt, {"no_data": True})
|
||||
|
||||
steps_sig = next((t for t in health if str(t.get("id", "")).endswith(":steps")), None)
|
||||
sleep_sig = next((t for t in health if str(t.get("id", "")).endswith(":sleep")), None)
|
||||
activity_sig = next((t for t in health if str(t.get("id", "")).endswith(":activity")), None)
|
||||
hr_sig = next((t for t in health if str(t.get("id", "")).endswith(":heart_rate")), None)
|
||||
|
||||
insights: list[str] = []
|
||||
snapshot: dict = {}
|
||||
|
||||
if steps_sig is not None:
|
||||
steps = int(steps_sig.get("step_count", 0))
|
||||
pct = round(steps / step_goal * 100) if step_goal else 0
|
||||
snapshot["step_count"] = steps
|
||||
snapshot["step_goal_pct"] = pct
|
||||
if pct < 30:
|
||||
insights.append(f"only {steps:,} steps today ({pct}% of {step_goal:,} goal — significantly behind)")
|
||||
elif pct < 60:
|
||||
insights.append(f"{steps:,} steps today ({pct}% of {step_goal:,} goal)")
|
||||
elif pct >= 100:
|
||||
insights.append(f"{steps:,} steps today (daily goal reached!)")
|
||||
else:
|
||||
insights.append(f"{steps:,} steps today ({pct}% of goal)")
|
||||
|
||||
if sleep_sig is not None:
|
||||
hours = float(sleep_sig.get("sleep_hours", 0))
|
||||
deficit = max(0.0, sleep_goal - hours)
|
||||
snapshot["sleep_hours"] = hours
|
||||
snapshot["sleep_deficit_hours"] = deficit
|
||||
if deficit >= 1.5:
|
||||
insights.append(f"only {hours:.1f}h sleep last night ({deficit:.1f}h below the {sleep_goal:.0f}h goal)")
|
||||
elif deficit > 0:
|
||||
insights.append(f"{hours:.1f}h sleep last night (slightly below {sleep_goal:.0f}h goal)")
|
||||
else:
|
||||
insights.append(f"{hours:.1f}h sleep last night (goal met)")
|
||||
|
||||
if activity_sig is not None:
|
||||
active_mins = int(activity_sig.get("active_minutes", 0))
|
||||
calories = int(activity_sig.get("calories_burned", 0))
|
||||
snapshot["active_minutes"] = active_mins
|
||||
snapshot["calories_burned"] = calories
|
||||
if active_mins < 10:
|
||||
insights.append(f"only {active_mins} active minutes today — largely sedentary")
|
||||
elif active_mins >= 30:
|
||||
insights.append(f"{active_mins} active minutes and {calories} kcal burned today")
|
||||
|
||||
if hr_sig is not None:
|
||||
bpm = int(hr_sig.get("resting_bpm", 0))
|
||||
snapshot["resting_bpm"] = bpm
|
||||
if bpm > 90:
|
||||
insights.append(f"elevated resting heart rate: {bpm} bpm")
|
||||
elif bpm > 0:
|
||||
insights.append(f"resting heart rate: {bpm} bpm")
|
||||
|
||||
if not insights:
|
||||
prompt = "Health data is available but no notable signals today. (Always write the tip in English.)"
|
||||
else:
|
||||
body = "; ".join(insights)
|
||||
prompt = f"Health snapshot: {body}. (Always write the tip in English.)"
|
||||
|
||||
return self._make_output(inp, prompt, snapshot)
|
||||
@@ -16,6 +16,7 @@ from .momentum import MomentumAgent, MANIFEST as MOMENTUM_MANIFEST
|
||||
from .time_of_day import TimeOfDayAgent, MANIFEST as TIME_OF_DAY_MANIFEST
|
||||
from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
|
||||
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_MANIFEST
|
||||
from .health_vitals import HealthVitalsAgent, MANIFEST as HEALTH_VITALS_MANIFEST
|
||||
|
||||
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
||||
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
||||
@@ -23,6 +24,7 @@ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
||||
(TimeOfDayAgent(), TIME_OF_DAY_MANIFEST),
|
||||
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
|
||||
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
||||
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
|
||||
]
|
||||
|
||||
# Sanity check — agent_id and manifest.id must agree, otherwise the registry
|
||||
|
||||
@@ -255,7 +255,7 @@ class TestRegistry:
|
||||
def test_all_agents_present(self):
|
||||
agents = all_agents()
|
||||
ids = {a.agent_id for a in agents}
|
||||
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area"}
|
||||
assert ids == {"overdue-task", "momentum", "time-of-day", "recent-patterns", "focus-area", "health-vitals"}
|
||||
|
||||
def test_get_agent(self):
|
||||
a = get_agent("momentum")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type IntegrationProvider = 'todoist';
|
||||
export type IntegrationProvider = 'todoist' | 'google-health';
|
||||
export type IntegrationStatus = 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export interface Integration {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export interface Signal {
|
||||
id: string;
|
||||
source: string; // e.g. 'todoist', 'google-calendar', 'manual'
|
||||
kind: 'task' | 'event' | 'habit' | 'insight';
|
||||
kind: 'task' | 'event' | 'habit' | 'insight' | 'health';
|
||||
content: string;
|
||||
metadata: Record<string, unknown>; // source-specific raw fields
|
||||
features: Record<string, number | boolean>; // bandit-ready numeric/boolean features
|
||||
|
||||
@@ -6,12 +6,13 @@ import { eq, and, gt, lt } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getProfile, type Profile } from '../profile/builder.js';
|
||||
import { todoistSource } from '../signals/todoist.js';
|
||||
import { googleHealthSource } from '../signals/google-health.js';
|
||||
import { SignalAggregator } from '../signals/aggregator.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Separate aggregator instance — avoids circular dep with recommender.ts.
|
||||
const _agentAggregator = new SignalAggregator().register(todoistSource);
|
||||
const _agentAggregator = new SignalAggregator().register(todoistSource).register(googleHealthSource);
|
||||
|
||||
// ── Internal auth helper ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -132,11 +133,16 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
|
||||
const signals = await _agentAggregator.fetchAll(userId);
|
||||
tasks = signals.map((s) => ({
|
||||
id: s.id,
|
||||
source: s.source,
|
||||
kind: s.kind,
|
||||
content: s.content,
|
||||
// Task-specific fields (default to harmless values for non-task signals)
|
||||
priority: (s.features.priority as number) ?? 1,
|
||||
is_overdue: Boolean(s.features.is_overdue),
|
||||
task_age_days: (s.features.task_age_days as number) ?? 0,
|
||||
project_id: (s.metadata as Record<string, unknown>).project_id ?? null,
|
||||
// All features spread so source-specific agents (e.g. health-vitals) can read them
|
||||
...s.features,
|
||||
}));
|
||||
} catch {
|
||||
// No integration or fetch error — agents that need tasks will report "no tasks"
|
||||
|
||||
@@ -12,6 +12,24 @@ const TODOIST_OAUTH_URL = 'https://todoist.com/oauth/authorize';
|
||||
const TODOIST_TOKEN_URL = 'https://todoist.com/oauth/access_token';
|
||||
const TODOIST_SCOPES = 'data:read_write';
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
|
||||
|
||||
const GOOGLE_HEALTH_SCOPES = [
|
||||
'https://www.googleapis.com/auth/fitness.activity.read',
|
||||
'https://www.googleapis.com/auth/fitness.body.read',
|
||||
'https://www.googleapis.com/auth/fitness.sleep.read',
|
||||
'https://www.googleapis.com/auth/fitness.heart_rate.read',
|
||||
'https://www.googleapis.com/auth/fitness.nutrition.read',
|
||||
'https://www.googleapis.com/auth/fitness.location.read',
|
||||
'https://www.googleapis.com/auth/fitness.blood_glucose.read',
|
||||
'https://www.googleapis.com/auth/fitness.blood_pressure.read',
|
||||
'https://www.googleapis.com/auth/fitness.body_temperature.read',
|
||||
'https://www.googleapis.com/auth/fitness.oxygen_saturation.read',
|
||||
'https://www.googleapis.com/auth/fitness.reproductive_health.read',
|
||||
].join(' ');
|
||||
|
||||
// In-memory CSRF state store
|
||||
const pendingStates = new Map<string, { userId: string; redirectTo: string }>();
|
||||
|
||||
@@ -104,6 +122,96 @@ router.get('/todoist/callback', async (req: Request, res: Response) => {
|
||||
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=todoist`);
|
||||
});
|
||||
|
||||
/** GET /api/integrations/google-health/connect — start Google Fit OAuth */
|
||||
router.get('/google-health/connect', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||
const state = nanoid();
|
||||
pendingStates.set(state, {
|
||||
userId: req.userId!,
|
||||
redirectTo: (req.query.redirectTo as string) ?? '/connect',
|
||||
});
|
||||
setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000);
|
||||
|
||||
const url = new URL(GOOGLE_AUTH_URL);
|
||||
url.searchParams.set('client_id', config.GOOGLE_CLIENT_ID);
|
||||
url.searchParams.set('redirect_uri', `${config.API_BASE_URL}/api/integrations/google-health/callback`);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', GOOGLE_HEALTH_SCOPES);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('access_type', 'offline');
|
||||
url.searchParams.set('prompt', 'consent');
|
||||
|
||||
res.redirect(url.toString());
|
||||
});
|
||||
|
||||
/** GET /api/integrations/google-health/callback — Google returns here */
|
||||
router.get('/google-health/callback', async (req: Request, res: Response) => {
|
||||
const state = req.query.state as string;
|
||||
const code = req.query.code as string;
|
||||
const error = req.query.error as string | undefined;
|
||||
|
||||
if (error) {
|
||||
res.status(400).json({ error: `Google denied access: ${error}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingStates.get(state);
|
||||
if (!pending) {
|
||||
res.status(400).json({ error: 'Invalid or expired state' });
|
||||
return;
|
||||
}
|
||||
pendingStates.delete(state);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.GOOGLE_CLIENT_ID,
|
||||
client_secret: config.GOOGLE_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${config.API_BASE_URL}/api/integrations/google-health/callback`,
|
||||
});
|
||||
|
||||
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const detail = await tokenRes.text().catch(() => '');
|
||||
res.status(502).json({ error: `Failed to exchange Google token: ${detail}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenData = (await tokenRes.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + tokenData.expires_in * 1000).toISOString();
|
||||
|
||||
await db
|
||||
.delete(integrationTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(integrationTokens.userId, pending.userId),
|
||||
eq(integrationTokens.provider, 'google-health'),
|
||||
),
|
||||
);
|
||||
await db.insert(integrationTokens).values({
|
||||
id: nanoid(),
|
||||
userId: pending.userId,
|
||||
provider: 'google-health',
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token ?? null,
|
||||
expiresAt,
|
||||
tokenStatus: 'active',
|
||||
connectedAt: now.toISOString(),
|
||||
});
|
||||
|
||||
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=google-health`);
|
||||
});
|
||||
|
||||
/** DELETE /api/integrations/:provider — revoke token */
|
||||
router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const provider = String(req.params.provider);
|
||||
@@ -120,13 +228,16 @@ router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res:
|
||||
.limit(1);
|
||||
|
||||
if (token?.provider === 'todoist') {
|
||||
// Best-effort revocation
|
||||
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.accessToken}` },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (token?.provider === 'google-health') {
|
||||
await fetch(`${GOOGLE_REVOKE_URL}?token=${token.accessToken}`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(integrationTokens)
|
||||
.where(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { bus } from '../events/bus.js';
|
||||
import type { TipCandidate, Signal } from '@oo/shared-types';
|
||||
import { todoistSource, dueAgeDays } from '../signals/todoist.js';
|
||||
export { dueAgeDays };
|
||||
import { googleHealthSource } from '../signals/google-health.js';
|
||||
import { SignalAggregator } from '../signals/aggregator.js';
|
||||
import { getActiveAgentOutputs } from './agent-outputs.js';
|
||||
import { getEligibleAgentIds } from '../profile/eligibility.js';
|
||||
@@ -19,8 +20,11 @@ const router: ExpressRouter = Router();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal aggregator — register sources here as new integrations are added
|
||||
// ---------------------------------------------------------------------------
|
||||
export const aggregator = new SignalAggregator().register(todoistSource);
|
||||
export const _clearSignalCacheForTests = () => todoistSource.clearCache();
|
||||
export const aggregator = new SignalAggregator().register(todoistSource).register(googleHealthSource);
|
||||
export const _clearSignalCacheForTests = () => {
|
||||
todoistSource.clearCache();
|
||||
googleHealthSource.clearCache();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal → TipCandidate conversion
|
||||
|
||||
312
services/api/src/signals/google-health.ts
Normal file
312
services/api/src/signals/google-health.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import type { Signal, SignalSource } from '@oo/shared-types';
|
||||
import { db } from '../db/index.js';
|
||||
import { integrationTokens } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { bus } from '../events/bus.js';
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60_000;
|
||||
const FIT_AGGREGATE_URL = 'https://www.googleapis.com/fitness/v1/users/me/dataset:aggregate';
|
||||
const FIT_SESSIONS_URL = 'https://www.googleapis.com/fitness/v1/users/me/sessions';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
const STEP_DAILY_GOAL = 7_000;
|
||||
const SLEEP_GOAL_HOURS = 7;
|
||||
|
||||
interface FitBucket {
|
||||
dataset: Array<{
|
||||
dataSourceId: string;
|
||||
point: Array<{ value: Array<{ intVal?: number; fpVal?: number }> }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FitAggregateResponse {
|
||||
bucket?: FitBucket[];
|
||||
}
|
||||
|
||||
interface FitSession {
|
||||
name: string;
|
||||
startTimeMillis: string;
|
||||
endTimeMillis: string;
|
||||
activityType: number;
|
||||
}
|
||||
|
||||
interface FitSessionsResponse {
|
||||
session?: FitSession[];
|
||||
}
|
||||
|
||||
async function refreshGoogleToken(
|
||||
userId: string,
|
||||
refreshToken: string,
|
||||
): Promise<string | null> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.GOOGLE_CLIENT_ID,
|
||||
client_secret: config.GOOGLE_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
|
||||
const res = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as { access_token: string; expires_in: number };
|
||||
const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();
|
||||
|
||||
await db
|
||||
.update(integrationTokens)
|
||||
.set({ accessToken: data.access_token, expiresAt, tokenStatus: 'active' })
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
function todayMidnightMs(): number {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function yesterdayIso(): string {
|
||||
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
async function fetchAggregates(
|
||||
token: string,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): Promise<FitAggregateResponse> {
|
||||
const res = await fetch(FIT_AGGREGATE_URL, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aggregateBy: [
|
||||
{ dataTypeName: 'com.google.step_count.delta' },
|
||||
{ dataTypeName: 'com.google.calories.expended' },
|
||||
{ dataTypeName: 'com.google.active_minutes' },
|
||||
{ dataTypeName: 'com.google.heart_rate.bpm' },
|
||||
],
|
||||
bucketByTime: { durationMillis: endMs - startMs },
|
||||
startTimeMillis: String(startMs),
|
||||
endTimeMillis: String(endMs),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Fit aggregate: ${res.status}`);
|
||||
return res.json() as Promise<FitAggregateResponse>;
|
||||
}
|
||||
|
||||
async function fetchSleepSessions(token: string): Promise<FitSessionsResponse> {
|
||||
const url = new URL(FIT_SESSIONS_URL);
|
||||
url.searchParams.set('activityType', '72');
|
||||
url.searchParams.set('startTime', yesterdayIso());
|
||||
url.searchParams.set('endTime', new Date().toISOString());
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Fit sessions: ${res.status}`);
|
||||
return res.json() as Promise<FitSessionsResponse>;
|
||||
}
|
||||
|
||||
function extractMetric(
|
||||
bucket: FitBucket,
|
||||
dataTypeName: string,
|
||||
valueKey: 'intVal' | 'fpVal',
|
||||
): number {
|
||||
for (const ds of bucket.dataset) {
|
||||
if (!ds.dataSourceId.includes(dataTypeName.replace('com.google.', '').replace('.', '_'))) continue;
|
||||
for (const pt of ds.point) {
|
||||
const v = pt.value[0];
|
||||
if (v) return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractAnyMetric(
|
||||
bucket: FitBucket,
|
||||
typeSuffix: string,
|
||||
valueKey: 'intVal' | 'fpVal',
|
||||
): number {
|
||||
for (const ds of bucket.dataset) {
|
||||
if (!ds.dataSourceId.includes(typeSuffix)) continue;
|
||||
const pt = ds.point[0];
|
||||
if (pt?.value[0]) {
|
||||
const v = pt.value[0];
|
||||
return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export class GoogleHealthSignalSource implements SignalSource {
|
||||
readonly id = 'google-health';
|
||||
|
||||
private cache = new Map<string, { signals: Signal[]; fetchedAt: number }>();
|
||||
|
||||
clearCache(userId?: string): void {
|
||||
if (userId) this.cache.delete(userId);
|
||||
else this.cache.clear();
|
||||
}
|
||||
|
||||
async fetchSignals(userId: string): Promise<Signal[]> {
|
||||
const entry = this.cache.get(userId);
|
||||
if (entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS) return entry.signals;
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(integrationTokens)
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return [];
|
||||
|
||||
let token = row.accessToken;
|
||||
const isExpired = row.expiresAt && new Date(row.expiresAt).getTime() - Date.now() < 5 * 60_000;
|
||||
|
||||
if (isExpired && row.refreshToken) {
|
||||
const refreshed = await refreshGoogleToken(userId, row.refreshToken);
|
||||
if (!refreshed) {
|
||||
logger.warn({ userId }, 'google-health: refresh failed');
|
||||
await db
|
||||
.update(integrationTokens)
|
||||
.set({ tokenStatus: 'needs_reconnect' })
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
|
||||
bus.publish('signals.integration.token_expired', {
|
||||
userId,
|
||||
provider: 'google-health',
|
||||
detectedAt: new Date().toISOString(),
|
||||
});
|
||||
return entry?.signals ?? [];
|
||||
}
|
||||
token = refreshed;
|
||||
}
|
||||
|
||||
try {
|
||||
const startMs = todayMidnightMs();
|
||||
const endMs = Date.now();
|
||||
|
||||
const [aggData, sleepData] = await Promise.all([
|
||||
fetchAggregates(token, startMs, endMs),
|
||||
fetchSleepSessions(token),
|
||||
]);
|
||||
|
||||
const bucket = aggData.bucket?.[0];
|
||||
const signals: Signal[] = [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (bucket) {
|
||||
// Steps
|
||||
const steps = extractAnyMetric(bucket, 'step_count', 'intVal');
|
||||
const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100);
|
||||
signals.push({
|
||||
id: `google-health:steps`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `${steps.toLocaleString()} steps today (${stepGoalPct}% of ${STEP_DAILY_GOAL.toLocaleString()} goal)`,
|
||||
metadata: { dataType: 'steps' },
|
||||
features: {
|
||||
step_count: steps,
|
||||
step_goal_pct: stepGoalPct,
|
||||
step_goal: STEP_DAILY_GOAL,
|
||||
below_step_goal: steps < STEP_DAILY_GOAL,
|
||||
},
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Calories + active minutes
|
||||
const calories = Math.round(extractAnyMetric(bucket, 'calories', 'fpVal'));
|
||||
const activeMinutes = extractAnyMetric(bucket, 'active_minutes', 'intVal');
|
||||
signals.push({
|
||||
id: `google-health:activity`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `${activeMinutes} active minutes, ${calories} calories burned today`,
|
||||
metadata: { dataType: 'activity' },
|
||||
features: {
|
||||
active_minutes: activeMinutes,
|
||||
calories_burned: calories,
|
||||
sedentary: activeMinutes < 20,
|
||||
},
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Heart rate
|
||||
const bpm = Math.round(extractAnyMetric(bucket, 'heart_rate', 'fpVal'));
|
||||
if (bpm > 0) {
|
||||
signals.push({
|
||||
id: `google-health:heart_rate`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `Resting heart rate: ${bpm} bpm`,
|
||||
metadata: { dataType: 'heart_rate' },
|
||||
features: { resting_bpm: bpm, elevated_hr: bpm > 90 },
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep — find the most recent sleep session
|
||||
if (sleepData.session?.length) {
|
||||
const sorted = [...sleepData.session].sort(
|
||||
(a, b) => Number(b.endTimeMillis) - Number(a.endTimeMillis),
|
||||
);
|
||||
const last = sorted[0]!;
|
||||
const durationMs = Number(last.endTimeMillis) - Number(last.startTimeMillis);
|
||||
const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10;
|
||||
const belowGoal = sleepHours < SLEEP_GOAL_HOURS;
|
||||
signals.push({
|
||||
id: `google-health:sleep`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `${sleepHours}h sleep last night (${belowGoal ? 'below' : 'meets'} ${SLEEP_GOAL_HOURS}h goal)`,
|
||||
metadata: { dataType: 'sleep', sessionName: last.name },
|
||||
features: {
|
||||
sleep_hours: sleepHours,
|
||||
sleep_goal_hours: SLEEP_GOAL_HOURS,
|
||||
sleep_deficit_hours: Math.max(0, SLEEP_GOAL_HOURS - sleepHours),
|
||||
below_sleep_goal: belowGoal,
|
||||
},
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
|
||||
this.cache.set(userId, { signals, fetchedAt: Date.now() });
|
||||
bus.publish('signals.task.synced', {
|
||||
userId,
|
||||
source: 'google-health',
|
||||
count: signals.length,
|
||||
syncedAt: now,
|
||||
});
|
||||
|
||||
return signals;
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { message?: string }).message;
|
||||
if (status?.includes('401')) {
|
||||
logger.warn({ userId }, 'google-health: token expired (401)');
|
||||
if (row.refreshToken) {
|
||||
await refreshGoogleToken(userId, row.refreshToken);
|
||||
} else {
|
||||
await db
|
||||
.update(integrationTokens)
|
||||
.set({ tokenStatus: 'needs_reconnect' })
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
|
||||
bus.publish('signals.integration.token_expired', {
|
||||
userId,
|
||||
provider: 'google-health',
|
||||
detectedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.error({ userId, err }, 'google-health: fetch failed');
|
||||
}
|
||||
return entry?.signals ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const googleHealthSource = new GoogleHealthSignalSource();
|
||||
Reference in New Issue
Block a user