feat(integrations): migrate google-health from Fit REST to Google Health API v4
Google Fit REST API was closed to new sign-ups on 2024-05-01 and shuts down
end of 2026, surfacing as "Access blocked: this app's request is invalid"
when starting the OAuth flow.
- Swap the 10 fitness.* OAuth scopes for the 3 googlehealth.*.readonly
scopes (activity_and_fitness, health_metrics_and_measurements, sleep).
- Replace fitness/v1 dataset:aggregate + sessions calls with
health.googleapis.com/v4/users/me/dataTypes/{steps,total-calories,
heart-rate,sleep}/dataPoints, filtered to today's window.
- Read the v4 DataPoint union defensively (the per-type schema is sparsely
documented) and log the first raw sample at debug so we can refine field
paths after the first real OAuth.
- Output Signal contract is unchanged — agents and downstream consumers
see the same steps/activity/heart_rate/sleep signals.
Cloud Console still needs: enable Google Health API, add the 3 scopes to
the consent screen, add test user (all googlehealth scopes are Restricted).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17,17 +17,9 @@ 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',
|
||||
'https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly',
|
||||
'https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly',
|
||||
'https://www.googleapis.com/auth/googlehealth.sleep.readonly',
|
||||
].join(' ');
|
||||
|
||||
// In-memory CSRF state store
|
||||
|
||||
@@ -7,33 +7,20 @@ 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 HEALTH_API_BASE = 'https://health.googleapis.com/v4/users/me/dataTypes';
|
||||
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 }> }>;
|
||||
}>;
|
||||
// v4 DataPoint shape is a union keyed by data type; we read defensively.
|
||||
interface DataPoint {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FitAggregateResponse {
|
||||
bucket?: FitBucket[];
|
||||
}
|
||||
|
||||
interface FitSession {
|
||||
name: string;
|
||||
startTimeMillis: string;
|
||||
endTimeMillis: string;
|
||||
activityType: number;
|
||||
}
|
||||
|
||||
interface FitSessionsResponse {
|
||||
session?: FitSession[];
|
||||
interface DataPointsResponse {
|
||||
dataPoints?: DataPoint[];
|
||||
nextPageToken?: string;
|
||||
}
|
||||
|
||||
async function refreshGoogleToken(
|
||||
@@ -66,81 +53,62 @@ async function refreshGoogleToken(
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
function todayMidnightMs(): number {
|
||||
function todayMidnightIso(): string {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function yesterdayIso(): string {
|
||||
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
async function fetchAggregates(
|
||||
async function fetchDataPoints(
|
||||
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());
|
||||
dataType: string,
|
||||
filter: string,
|
||||
): Promise<DataPoint[]> {
|
||||
const url = new URL(`${HEALTH_API_BASE}/${dataType}/dataPoints`);
|
||||
url.searchParams.set('filter', filter);
|
||||
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>;
|
||||
if (!res.ok) throw new Error(`health ${dataType}: ${res.status}`);
|
||||
const data = (await res.json()) as DataPointsResponse;
|
||||
return data.dataPoints ?? [];
|
||||
}
|
||||
|
||||
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);
|
||||
// Defensive numeric reader — probes likely field names in a v4 DataPoint payload.
|
||||
function readNumber(point: DataPoint, paths: string[][]): number {
|
||||
for (const path of paths) {
|
||||
let cur: unknown = point;
|
||||
for (const key of path) {
|
||||
if (cur && typeof cur === 'object' && key in (cur as object)) {
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
} else {
|
||||
cur = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (typeof cur === 'number') return cur;
|
||||
}
|
||||
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);
|
||||
function readString(point: DataPoint, paths: string[][]): string | undefined {
|
||||
for (const path of paths) {
|
||||
let cur: unknown = point;
|
||||
for (const key of path) {
|
||||
if (cur && typeof cur === 'object' && key in (cur as object)) {
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
} else {
|
||||
cur = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (typeof cur === 'string') return cur;
|
||||
}
|
||||
return 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class GoogleHealthSignalSource implements SignalSource {
|
||||
@@ -187,57 +155,76 @@ export class GoogleHealthSignalSource implements SignalSource {
|
||||
}
|
||||
|
||||
try {
|
||||
const startMs = todayMidnightMs();
|
||||
const endMs = Date.now();
|
||||
const dayStartIso = todayMidnightIso();
|
||||
const dayEndIso = new Date().toISOString();
|
||||
const yIso = yesterdayIso();
|
||||
|
||||
const [aggData, sleepData] = await Promise.all([
|
||||
fetchAggregates(token, startMs, endMs),
|
||||
fetchSleepSessions(token),
|
||||
const stepsFilter = `steps.interval.start_time >= "${dayStartIso}" AND steps.interval.start_time < "${dayEndIso}"`;
|
||||
const caloriesFilter = `total_calories.interval.start_time >= "${dayStartIso}" AND total_calories.interval.start_time < "${dayEndIso}"`;
|
||||
const hrFilter = `heart_rate.sample_time.physical_time >= "${dayStartIso}" AND heart_rate.sample_time.physical_time < "${dayEndIso}"`;
|
||||
const sleepFilter = `sleep.interval.start_time >= "${yIso}" AND sleep.interval.start_time < "${dayEndIso}"`;
|
||||
|
||||
const [stepsPts, caloriesPts, hrPts, sleepPts] = await Promise.all([
|
||||
fetchDataPoints(token, 'steps', stepsFilter),
|
||||
fetchDataPoints(token, 'total-calories', caloriesFilter),
|
||||
fetchDataPoints(token, 'heart-rate', hrFilter),
|
||||
fetchDataPoints(token, 'sleep', sleepFilter),
|
||||
]);
|
||||
|
||||
const bucket = aggData.bucket?.[0];
|
||||
// One-time peek at raw shape so we can refine field paths after first real OAuth.
|
||||
logger.debug(
|
||||
{ userId, samples: { stepsPts: stepsPts.slice(0, 1), caloriesPts: caloriesPts.slice(0, 1), hrPts: hrPts.slice(0, 1), sleepPts: sleepPts.slice(0, 1) } },
|
||||
'google-health: v4 dataPoints sample',
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
const steps = stepsPts.reduce(
|
||||
(sum, p) => sum + readNumber(p, [['steps', 'count'], ['count']]),
|
||||
0,
|
||||
);
|
||||
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,
|
||||
});
|
||||
const calories = Math.round(
|
||||
caloriesPts.reduce(
|
||||
(sum, p) =>
|
||||
sum + readNumber(p, [['totalCalories', 'kilocalories'], ['kilocalories'], ['energy', 'kilocalories']]),
|
||||
0,
|
||||
),
|
||||
);
|
||||
signals.push({
|
||||
id: `google-health:activity`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `${calories} calories burned today`,
|
||||
metadata: { dataType: 'activity' },
|
||||
features: {
|
||||
calories_burned: calories,
|
||||
},
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Heart rate
|
||||
const bpm = Math.round(extractAnyMetric(bucket, 'heart_rate', 'fpVal'));
|
||||
if (bpm > 0) {
|
||||
if (hrPts.length > 0) {
|
||||
const hrValues = hrPts
|
||||
.map((p) => readNumber(p, [['heartRate', 'beatsPerMinute'], ['beatsPerMinute']]))
|
||||
.filter((v) => v > 0);
|
||||
if (hrValues.length > 0) {
|
||||
const bpm = Math.round(hrValues.reduce((a, b) => a + b, 0) / hrValues.length);
|
||||
signals.push({
|
||||
id: `google-health:heart_rate`,
|
||||
source: 'google-health',
|
||||
@@ -250,29 +237,34 @@ export class GoogleHealthSignalSource implements SignalSource {
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
if (sleepPts.length > 0) {
|
||||
const sleepSessions = sleepPts
|
||||
.map((p) => ({
|
||||
start: readString(p, [['sleep', 'interval', 'startTime'], ['interval', 'startTime'], ['startTime']]),
|
||||
end: readString(p, [['sleep', 'interval', 'endTime'], ['interval', 'endTime'], ['endTime']]),
|
||||
}))
|
||||
.filter((s): s is { start: string; end: string } => !!s.start && !!s.end)
|
||||
.sort((a, b) => Date.parse(b.end) - Date.parse(a.end));
|
||||
const last = sleepSessions[0];
|
||||
if (last) {
|
||||
const durationMs = Date.parse(last.end) - Date.parse(last.start);
|
||||
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' },
|
||||
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() });
|
||||
|
||||
Reference in New Issue
Block a user