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 .time_of_day import TimeOfDayAgent, MANIFEST as TIME_OF_DAY_MANIFEST
|
||||||
from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
|
from .recent_patterns import RecentPatternsAgent, MANIFEST as RECENT_PATTERNS_MANIFEST
|
||||||
from .focus_area import FocusAreaAgent, MANIFEST as FOCUS_AREA_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]] = [
|
_REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
||||||
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
(OverdueTaskAgent(), OVERDUE_TASK_MANIFEST),
|
||||||
@@ -23,6 +24,7 @@ _REGISTERED: list[tuple[BaseAgent, AgentManifest]] = [
|
|||||||
(TimeOfDayAgent(), TIME_OF_DAY_MANIFEST),
|
(TimeOfDayAgent(), TIME_OF_DAY_MANIFEST),
|
||||||
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
|
(RecentPatternsAgent(), RECENT_PATTERNS_MANIFEST),
|
||||||
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
(FocusAreaAgent(), FOCUS_AREA_MANIFEST),
|
||||||
|
(HealthVitalsAgent(), HEALTH_VITALS_MANIFEST),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Sanity check — agent_id and manifest.id must agree, otherwise the registry
|
# Sanity check — agent_id and manifest.id must agree, otherwise the registry
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ class TestRegistry:
|
|||||||
def test_all_agents_present(self):
|
def test_all_agents_present(self):
|
||||||
agents = all_agents()
|
agents = all_agents()
|
||||||
ids = {a.agent_id for a in 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):
|
def test_get_agent(self):
|
||||||
a = get_agent("momentum")
|
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 type IntegrationStatus = 'connected' | 'disconnected' | 'error';
|
||||||
|
|
||||||
export interface Integration {
|
export interface Integration {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
export interface Signal {
|
export interface Signal {
|
||||||
id: string;
|
id: string;
|
||||||
source: string; // e.g. 'todoist', 'google-calendar', 'manual'
|
source: string; // e.g. 'todoist', 'google-calendar', 'manual'
|
||||||
kind: 'task' | 'event' | 'habit' | 'insight';
|
kind: 'task' | 'event' | 'habit' | 'insight' | 'health';
|
||||||
content: string;
|
content: string;
|
||||||
metadata: Record<string, unknown>; // source-specific raw fields
|
metadata: Record<string, unknown>; // source-specific raw fields
|
||||||
features: Record<string, number | boolean>; // bandit-ready numeric/boolean features
|
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 { config } from '../config.js';
|
||||||
import { getProfile, type Profile } from '../profile/builder.js';
|
import { getProfile, type Profile } from '../profile/builder.js';
|
||||||
import { todoistSource } from '../signals/todoist.js';
|
import { todoistSource } from '../signals/todoist.js';
|
||||||
|
import { googleHealthSource } from '../signals/google-health.js';
|
||||||
import { SignalAggregator } from '../signals/aggregator.js';
|
import { SignalAggregator } from '../signals/aggregator.js';
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
// Separate aggregator instance — avoids circular dep with recommender.ts.
|
// 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 ──────────────────────────────────────────────────────
|
// ── Internal auth helper ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -132,11 +133,16 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
|
|||||||
const signals = await _agentAggregator.fetchAll(userId);
|
const signals = await _agentAggregator.fetchAll(userId);
|
||||||
tasks = signals.map((s) => ({
|
tasks = signals.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
|
source: s.source,
|
||||||
|
kind: s.kind,
|
||||||
content: s.content,
|
content: s.content,
|
||||||
|
// Task-specific fields (default to harmless values for non-task signals)
|
||||||
priority: (s.features.priority as number) ?? 1,
|
priority: (s.features.priority as number) ?? 1,
|
||||||
is_overdue: Boolean(s.features.is_overdue),
|
is_overdue: Boolean(s.features.is_overdue),
|
||||||
task_age_days: (s.features.task_age_days as number) ?? 0,
|
task_age_days: (s.features.task_age_days as number) ?? 0,
|
||||||
project_id: (s.metadata as Record<string, unknown>).project_id ?? null,
|
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 {
|
} catch {
|
||||||
// No integration or fetch error — agents that need tasks will report "no tasks"
|
// 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_TOKEN_URL = 'https://todoist.com/oauth/access_token';
|
||||||
const TODOIST_SCOPES = 'data:read_write';
|
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
|
// In-memory CSRF state store
|
||||||
const pendingStates = new Map<string, { userId: string; redirectTo: string }>();
|
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`);
|
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 */
|
/** DELETE /api/integrations/:provider — revoke token */
|
||||||
router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||||
const provider = String(req.params.provider);
|
const provider = String(req.params.provider);
|
||||||
@@ -120,13 +228,16 @@ router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res:
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (token?.provider === 'todoist') {
|
if (token?.provider === 'todoist') {
|
||||||
// Best-effort revocation
|
|
||||||
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
|
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token.accessToken}` },
|
headers: { Authorization: `Bearer ${token.accessToken}` },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token?.provider === 'google-health') {
|
||||||
|
await fetch(`${GOOGLE_REVOKE_URL}?token=${token.accessToken}`, { method: 'POST' }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(integrationTokens)
|
.delete(integrationTokens)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { bus } from '../events/bus.js';
|
|||||||
import type { TipCandidate, Signal } from '@oo/shared-types';
|
import type { TipCandidate, Signal } from '@oo/shared-types';
|
||||||
import { todoistSource, dueAgeDays } from '../signals/todoist.js';
|
import { todoistSource, dueAgeDays } from '../signals/todoist.js';
|
||||||
export { dueAgeDays };
|
export { dueAgeDays };
|
||||||
|
import { googleHealthSource } from '../signals/google-health.js';
|
||||||
import { SignalAggregator } from '../signals/aggregator.js';
|
import { SignalAggregator } from '../signals/aggregator.js';
|
||||||
import { getActiveAgentOutputs } from './agent-outputs.js';
|
import { getActiveAgentOutputs } from './agent-outputs.js';
|
||||||
import { getEligibleAgentIds } from '../profile/eligibility.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
|
// Signal aggregator — register sources here as new integrations are added
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export const aggregator = new SignalAggregator().register(todoistSource);
|
export const aggregator = new SignalAggregator().register(todoistSource).register(googleHealthSource);
|
||||||
export const _clearSignalCacheForTests = () => todoistSource.clearCache();
|
export const _clearSignalCacheForTests = () => {
|
||||||
|
todoistSource.clearCache();
|
||||||
|
googleHealthSource.clearCache();
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Signal → TipCandidate conversion
|
// 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