@contractspec/integration.providers-impls 2.10.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -10
- package/dist/impls/async-event-queue.d.ts +8 -0
- package/dist/impls/async-event-queue.js +49 -0
- package/dist/impls/composio-fallback-resolver.d.ts +34 -0
- package/dist/impls/composio-fallback-resolver.js +580 -0
- package/dist/impls/composio-mcp.d.ts +22 -0
- package/dist/impls/composio-mcp.js +164 -0
- package/dist/impls/composio-proxies.d.ts +60 -0
- package/dist/impls/composio-proxies.js +311 -0
- package/dist/impls/composio-sdk.d.ts +25 -0
- package/dist/impls/composio-sdk.js +78 -0
- package/dist/impls/composio-types.d.ts +43 -0
- package/dist/impls/composio-types.js +54 -0
- package/dist/impls/elevenlabs-voice.js +2 -0
- package/dist/impls/fal-voice.js +2 -0
- package/dist/impls/fathom-meeting-recorder.js +2 -0
- package/dist/impls/fathom-meeting-recorder.mapper.js +2 -0
- package/dist/impls/fathom-meeting-recorder.utils.js +2 -0
- package/dist/impls/fathom-meeting-recorder.webhooks.js +2 -0
- package/dist/impls/fireflies-meeting-recorder.js +2 -0
- package/dist/impls/fireflies-meeting-recorder.queries.js +2 -0
- package/dist/impls/fireflies-meeting-recorder.utils.js +2 -0
- package/dist/impls/gcs-storage.js +2 -0
- package/dist/impls/gmail-inbound.js +2 -0
- package/dist/impls/gmail-outbound.js +2 -0
- package/dist/impls/google-calendar.js +2 -0
- package/dist/impls/gradium-voice.js +2 -0
- package/dist/impls/granola-meeting-recorder.js +2 -0
- package/dist/impls/granola-meeting-recorder.mcp.js +2 -0
- package/dist/impls/health/base-health-provider.d.ts +64 -13
- package/dist/impls/health/base-health-provider.js +508 -156
- package/dist/impls/health/hybrid-health-providers.d.ts +34 -0
- package/dist/impls/health/hybrid-health-providers.js +1090 -0
- package/dist/impls/health/official-health-providers.d.ts +78 -0
- package/dist/impls/health/official-health-providers.js +970 -0
- package/dist/impls/health/provider-normalizers.d.ts +28 -0
- package/dist/impls/health/provider-normalizers.js +289 -0
- package/dist/impls/health/providers.d.ts +2 -39
- package/dist/impls/health/providers.js +897 -184
- package/dist/impls/health-provider-factory.js +1011 -196
- package/dist/impls/index.d.ts +11 -0
- package/dist/impls/index.js +2588 -259
- package/dist/impls/jira.js +2 -0
- package/dist/impls/linear.js +2 -0
- package/dist/impls/messaging-github.d.ts +17 -0
- package/dist/impls/messaging-github.js +112 -0
- package/dist/impls/messaging-slack.d.ts +14 -0
- package/dist/impls/messaging-slack.js +82 -0
- package/dist/impls/messaging-whatsapp-meta.d.ts +13 -0
- package/dist/impls/messaging-whatsapp-meta.js +54 -0
- package/dist/impls/messaging-whatsapp-twilio.d.ts +13 -0
- package/dist/impls/messaging-whatsapp-twilio.js +84 -0
- package/dist/impls/mistral-conversational.d.ts +23 -0
- package/dist/impls/mistral-conversational.js +478 -0
- package/dist/impls/mistral-conversational.session.d.ts +32 -0
- package/dist/impls/mistral-conversational.session.js +208 -0
- package/dist/impls/mistral-embedding.js +2 -0
- package/dist/impls/mistral-llm.js +2 -0
- package/dist/impls/mistral-stt.d.ts +17 -0
- package/dist/impls/mistral-stt.js +169 -0
- package/dist/impls/notion.js +2 -0
- package/dist/impls/posthog-reader.js +2 -0
- package/dist/impls/posthog-utils.js +2 -0
- package/dist/impls/posthog.js +2 -0
- package/dist/impls/postmark-email.js +2 -0
- package/dist/impls/powens-client.js +2 -0
- package/dist/impls/powens-openbanking.js +2 -0
- package/dist/impls/provider-factory.d.ts +29 -1
- package/dist/impls/provider-factory.js +1985 -249
- package/dist/impls/qdrant-vector.js +2 -0
- package/dist/impls/stripe-payments.js +3 -1
- package/dist/impls/supabase-psql.js +2 -0
- package/dist/impls/supabase-vector.js +2 -0
- package/dist/impls/tldv-meeting-recorder.js +2 -0
- package/dist/impls/twilio-sms.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2615 -283
- package/dist/messaging.d.ts +1 -0
- package/dist/messaging.js +3 -0
- package/dist/node/impls/async-event-queue.js +49 -0
- package/dist/node/impls/composio-fallback-resolver.js +580 -0
- package/dist/node/impls/composio-mcp.js +164 -0
- package/dist/node/impls/composio-proxies.js +311 -0
- package/dist/node/impls/composio-sdk.js +78 -0
- package/dist/node/impls/composio-types.js +54 -0
- package/dist/node/impls/elevenlabs-voice.js +3 -0
- package/dist/node/impls/fal-voice.js +3 -0
- package/dist/node/impls/fathom-meeting-recorder.js +3 -0
- package/dist/node/impls/fathom-meeting-recorder.mapper.js +3 -0
- package/dist/node/impls/fathom-meeting-recorder.utils.js +3 -0
- package/dist/node/impls/fathom-meeting-recorder.webhooks.js +3 -0
- package/dist/node/impls/fireflies-meeting-recorder.js +3 -0
- package/dist/node/impls/fireflies-meeting-recorder.queries.js +3 -0
- package/dist/node/impls/fireflies-meeting-recorder.utils.js +3 -0
- package/dist/node/impls/gcs-storage.js +3 -0
- package/dist/node/impls/gmail-inbound.js +3 -0
- package/dist/node/impls/gmail-outbound.js +3 -0
- package/dist/node/impls/google-calendar.js +3 -0
- package/dist/node/impls/gradium-voice.js +3 -0
- package/dist/node/impls/granola-meeting-recorder.js +3 -0
- package/dist/node/impls/granola-meeting-recorder.mcp.js +3 -0
- package/dist/node/impls/health/base-health-provider.js +509 -156
- package/dist/node/impls/health/hybrid-health-providers.js +1090 -0
- package/dist/node/impls/health/official-health-providers.js +970 -0
- package/dist/node/impls/health/provider-normalizers.js +289 -0
- package/dist/node/impls/health/providers.js +898 -184
- package/dist/node/impls/health-provider-factory.js +1012 -196
- package/dist/node/impls/index.js +2589 -259
- package/dist/node/impls/jira.js +3 -0
- package/dist/node/impls/linear.js +3 -0
- package/dist/node/impls/messaging-github.js +112 -0
- package/dist/node/impls/messaging-slack.js +82 -0
- package/dist/node/impls/messaging-whatsapp-meta.js +54 -0
- package/dist/node/impls/messaging-whatsapp-twilio.js +84 -0
- package/dist/node/impls/mistral-conversational.js +478 -0
- package/dist/node/impls/mistral-conversational.session.js +208 -0
- package/dist/node/impls/mistral-embedding.js +3 -0
- package/dist/node/impls/mistral-llm.js +3 -0
- package/dist/node/impls/mistral-stt.js +169 -0
- package/dist/node/impls/notion.js +3 -0
- package/dist/node/impls/posthog-reader.js +3 -0
- package/dist/node/impls/posthog-utils.js +3 -0
- package/dist/node/impls/posthog.js +3 -0
- package/dist/node/impls/postmark-email.js +3 -0
- package/dist/node/impls/powens-client.js +3 -0
- package/dist/node/impls/powens-openbanking.js +3 -0
- package/dist/node/impls/provider-factory.js +1986 -249
- package/dist/node/impls/qdrant-vector.js +3 -0
- package/dist/node/impls/stripe-payments.js +4 -1
- package/dist/node/impls/supabase-psql.js +3 -0
- package/dist/node/impls/supabase-vector.js +3 -0
- package/dist/node/impls/tldv-meeting-recorder.js +3 -0
- package/dist/node/impls/twilio-sms.js +3 -0
- package/dist/node/index.js +2616 -283
- package/dist/node/messaging.js +2 -0
- package/dist/node/secrets/provider.js +3 -0
- package/dist/secrets/provider.js +2 -0
- package/package.json +219 -14
|
@@ -1,5 +1,286 @@
|
|
|
1
1
|
// @bun
|
|
2
|
+
var __require = import.meta.require;
|
|
3
|
+
|
|
4
|
+
// src/impls/health/provider-normalizers.ts
|
|
5
|
+
var DEFAULT_LIST_KEYS = [
|
|
6
|
+
"items",
|
|
7
|
+
"data",
|
|
8
|
+
"records",
|
|
9
|
+
"activities",
|
|
10
|
+
"workouts",
|
|
11
|
+
"sleep",
|
|
12
|
+
"biometrics",
|
|
13
|
+
"nutrition"
|
|
14
|
+
];
|
|
15
|
+
function asRecord(value) {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function asArray(value) {
|
|
22
|
+
return Array.isArray(value) ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
function readString(record, keys) {
|
|
25
|
+
if (!record)
|
|
26
|
+
return;
|
|
27
|
+
for (const key of keys) {
|
|
28
|
+
const value = record[key];
|
|
29
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
function readNumber(record, keys) {
|
|
36
|
+
if (!record)
|
|
37
|
+
return;
|
|
38
|
+
for (const key of keys) {
|
|
39
|
+
const value = record[key];
|
|
40
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
44
|
+
const parsed = Number(value);
|
|
45
|
+
if (Number.isFinite(parsed)) {
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
function readBoolean(record, keys) {
|
|
53
|
+
if (!record)
|
|
54
|
+
return;
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const value = record[key];
|
|
57
|
+
if (typeof value === "boolean") {
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
function extractList(payload, listKeys = DEFAULT_LIST_KEYS) {
|
|
64
|
+
const root = asRecord(payload);
|
|
65
|
+
if (!root) {
|
|
66
|
+
return asArray(payload)?.map((item) => asRecord(item)).filter((item) => Boolean(item)) ?? [];
|
|
67
|
+
}
|
|
68
|
+
for (const key of listKeys) {
|
|
69
|
+
const arrayValue = asArray(root[key]);
|
|
70
|
+
if (!arrayValue)
|
|
71
|
+
continue;
|
|
72
|
+
return arrayValue.map((item) => asRecord(item)).filter((item) => Boolean(item));
|
|
73
|
+
}
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
function extractPagination(payload) {
|
|
77
|
+
const root = asRecord(payload);
|
|
78
|
+
const nestedPagination = asRecord(root?.pagination);
|
|
79
|
+
const nextCursor = readString(nestedPagination, ["nextCursor", "next_cursor"]) ?? readString(root, [
|
|
80
|
+
"nextCursor",
|
|
81
|
+
"next_cursor",
|
|
82
|
+
"cursor",
|
|
83
|
+
"next_page_token"
|
|
84
|
+
]);
|
|
85
|
+
const hasMore = readBoolean(nestedPagination, ["hasMore", "has_more"]) ?? readBoolean(root, ["hasMore", "has_more"]);
|
|
86
|
+
return {
|
|
87
|
+
nextCursor,
|
|
88
|
+
hasMore: hasMore ?? Boolean(nextCursor)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function toHealthActivity(item, context, fallbackType = "activity") {
|
|
92
|
+
const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:${fallbackType}`;
|
|
93
|
+
const id = readString(item, ["id", "uuid", "workout_id"]) ?? `${context.providerKey}:activity:${externalId}`;
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
externalId,
|
|
97
|
+
tenantId: context.tenantId,
|
|
98
|
+
connectionId: context.connectionId ?? "unknown",
|
|
99
|
+
userId: readString(item, ["user_id", "userId", "athlete_id"]),
|
|
100
|
+
providerKey: context.providerKey,
|
|
101
|
+
activityType: readString(item, ["activity_type", "type", "sport_type", "sport"]) ?? fallbackType,
|
|
102
|
+
startedAt: readIsoDate(item, [
|
|
103
|
+
"started_at",
|
|
104
|
+
"start_time",
|
|
105
|
+
"start_date",
|
|
106
|
+
"created_at"
|
|
107
|
+
]),
|
|
108
|
+
endedAt: readIsoDate(item, ["ended_at", "end_time"]),
|
|
109
|
+
durationSeconds: readNumber(item, [
|
|
110
|
+
"duration_seconds",
|
|
111
|
+
"duration",
|
|
112
|
+
"elapsed_time"
|
|
113
|
+
]),
|
|
114
|
+
distanceMeters: readNumber(item, ["distance_meters", "distance"]),
|
|
115
|
+
caloriesKcal: readNumber(item, [
|
|
116
|
+
"calories_kcal",
|
|
117
|
+
"calories",
|
|
118
|
+
"active_kilocalories"
|
|
119
|
+
]),
|
|
120
|
+
steps: readNumber(item, ["steps"])?.valueOf(),
|
|
121
|
+
metadata: item
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function toHealthWorkout(item, context, fallbackType = "workout") {
|
|
125
|
+
const activity = toHealthActivity(item, context, fallbackType);
|
|
126
|
+
return {
|
|
127
|
+
id: activity.id,
|
|
128
|
+
externalId: activity.externalId,
|
|
129
|
+
tenantId: activity.tenantId,
|
|
130
|
+
connectionId: activity.connectionId,
|
|
131
|
+
userId: activity.userId,
|
|
132
|
+
providerKey: activity.providerKey,
|
|
133
|
+
workoutType: readString(item, [
|
|
134
|
+
"workout_type",
|
|
135
|
+
"sport_type",
|
|
136
|
+
"type",
|
|
137
|
+
"activity_type"
|
|
138
|
+
]) ?? fallbackType,
|
|
139
|
+
startedAt: activity.startedAt,
|
|
140
|
+
endedAt: activity.endedAt,
|
|
141
|
+
durationSeconds: activity.durationSeconds,
|
|
142
|
+
distanceMeters: activity.distanceMeters,
|
|
143
|
+
caloriesKcal: activity.caloriesKcal,
|
|
144
|
+
averageHeartRateBpm: readNumber(item, [
|
|
145
|
+
"average_heart_rate",
|
|
146
|
+
"avg_hr",
|
|
147
|
+
"average_heart_rate_bpm"
|
|
148
|
+
]),
|
|
149
|
+
maxHeartRateBpm: readNumber(item, [
|
|
150
|
+
"max_heart_rate",
|
|
151
|
+
"max_hr",
|
|
152
|
+
"max_heart_rate_bpm"
|
|
153
|
+
]),
|
|
154
|
+
metadata: item
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function toHealthSleep(item, context) {
|
|
158
|
+
const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:sleep`;
|
|
159
|
+
const id = readString(item, ["id", "uuid"]) ?? `${context.providerKey}:sleep:${externalId}`;
|
|
160
|
+
const startedAt = readIsoDate(item, ["started_at", "start_time", "bedtime_start", "start"]) ?? new Date(0).toISOString();
|
|
161
|
+
const endedAt = readIsoDate(item, ["ended_at", "end_time", "bedtime_end", "end"]) ?? startedAt;
|
|
162
|
+
return {
|
|
163
|
+
id,
|
|
164
|
+
externalId,
|
|
165
|
+
tenantId: context.tenantId,
|
|
166
|
+
connectionId: context.connectionId ?? "unknown",
|
|
167
|
+
userId: readString(item, ["user_id", "userId"]),
|
|
168
|
+
providerKey: context.providerKey,
|
|
169
|
+
startedAt,
|
|
170
|
+
endedAt,
|
|
171
|
+
durationSeconds: readNumber(item, [
|
|
172
|
+
"duration_seconds",
|
|
173
|
+
"duration",
|
|
174
|
+
"total_sleep_duration"
|
|
175
|
+
]),
|
|
176
|
+
deepSleepSeconds: readNumber(item, [
|
|
177
|
+
"deep_sleep_seconds",
|
|
178
|
+
"deep_sleep_duration"
|
|
179
|
+
]),
|
|
180
|
+
lightSleepSeconds: readNumber(item, [
|
|
181
|
+
"light_sleep_seconds",
|
|
182
|
+
"light_sleep_duration"
|
|
183
|
+
]),
|
|
184
|
+
remSleepSeconds: readNumber(item, [
|
|
185
|
+
"rem_sleep_seconds",
|
|
186
|
+
"rem_sleep_duration"
|
|
187
|
+
]),
|
|
188
|
+
awakeSeconds: readNumber(item, ["awake_seconds", "awake_time"]),
|
|
189
|
+
sleepScore: readNumber(item, ["sleep_score", "score"]),
|
|
190
|
+
metadata: item
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function toHealthBiometric(item, context, metricTypeFallback = "metric") {
|
|
194
|
+
const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:biometric`;
|
|
195
|
+
const id = readString(item, ["id", "uuid"]) ?? `${context.providerKey}:biometric:${externalId}`;
|
|
196
|
+
return {
|
|
197
|
+
id,
|
|
198
|
+
externalId,
|
|
199
|
+
tenantId: context.tenantId,
|
|
200
|
+
connectionId: context.connectionId ?? "unknown",
|
|
201
|
+
userId: readString(item, ["user_id", "userId"]),
|
|
202
|
+
providerKey: context.providerKey,
|
|
203
|
+
metricType: readString(item, ["metric_type", "metric", "type", "name"]) ?? metricTypeFallback,
|
|
204
|
+
value: readNumber(item, ["value", "score", "measurement"]) ?? 0,
|
|
205
|
+
unit: readString(item, ["unit"]),
|
|
206
|
+
measuredAt: readIsoDate(item, ["measured_at", "timestamp", "created_at"]) ?? new Date().toISOString(),
|
|
207
|
+
metadata: item
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function toHealthNutrition(item, context) {
|
|
211
|
+
const externalId = readString(item, ["external_id", "externalId", "uuid", "id"]) ?? `${context.providerKey}:nutrition`;
|
|
212
|
+
const id = readString(item, ["id", "uuid"]) ?? `${context.providerKey}:nutrition:${externalId}`;
|
|
213
|
+
return {
|
|
214
|
+
id,
|
|
215
|
+
externalId,
|
|
216
|
+
tenantId: context.tenantId,
|
|
217
|
+
connectionId: context.connectionId ?? "unknown",
|
|
218
|
+
userId: readString(item, ["user_id", "userId"]),
|
|
219
|
+
providerKey: context.providerKey,
|
|
220
|
+
loggedAt: readIsoDate(item, ["logged_at", "created_at", "date", "timestamp"]) ?? new Date().toISOString(),
|
|
221
|
+
caloriesKcal: readNumber(item, ["calories_kcal", "calories"]),
|
|
222
|
+
proteinGrams: readNumber(item, ["protein_grams", "protein"]),
|
|
223
|
+
carbsGrams: readNumber(item, ["carbs_grams", "carbs"]),
|
|
224
|
+
fatGrams: readNumber(item, ["fat_grams", "fat"]),
|
|
225
|
+
fiberGrams: readNumber(item, ["fiber_grams", "fiber"]),
|
|
226
|
+
hydrationMl: readNumber(item, ["hydration_ml", "water_ml", "water"]),
|
|
227
|
+
metadata: item
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
function toHealthConnectionStatus(payload, params, source) {
|
|
231
|
+
const record = asRecord(payload);
|
|
232
|
+
const rawStatus = readString(record, ["status", "connection_status", "health"]) ?? "healthy";
|
|
233
|
+
return {
|
|
234
|
+
tenantId: params.tenantId,
|
|
235
|
+
connectionId: params.connectionId,
|
|
236
|
+
status: rawStatus === "healthy" || rawStatus === "degraded" || rawStatus === "error" || rawStatus === "disconnected" ? rawStatus : "healthy",
|
|
237
|
+
source,
|
|
238
|
+
lastCheckedAt: readIsoDate(record, ["last_checked_at", "lastCheckedAt"]) ?? new Date().toISOString(),
|
|
239
|
+
errorCode: readString(record, ["error_code", "errorCode"]),
|
|
240
|
+
errorMessage: readString(record, ["error_message", "errorMessage"]),
|
|
241
|
+
metadata: asRecord(record?.metadata)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function toHealthWebhookEvent(payload, providerKey, verified) {
|
|
245
|
+
const record = asRecord(payload);
|
|
246
|
+
const entityType = readString(record, ["entity_type", "entityType", "type"]);
|
|
247
|
+
const normalizedEntityType = entityType === "activity" || entityType === "workout" || entityType === "sleep" || entityType === "biometric" || entityType === "nutrition" ? entityType : undefined;
|
|
248
|
+
return {
|
|
249
|
+
providerKey,
|
|
250
|
+
eventType: readString(record, ["event_type", "eventType", "event"]),
|
|
251
|
+
externalEntityId: readString(record, [
|
|
252
|
+
"external_entity_id",
|
|
253
|
+
"externalEntityId",
|
|
254
|
+
"entity_id",
|
|
255
|
+
"entityId",
|
|
256
|
+
"id"
|
|
257
|
+
]),
|
|
258
|
+
entityType: normalizedEntityType,
|
|
259
|
+
receivedAt: new Date().toISOString(),
|
|
260
|
+
verified,
|
|
261
|
+
payload,
|
|
262
|
+
metadata: asRecord(record?.metadata)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function readIsoDate(record, keys) {
|
|
266
|
+
const value = readString(record, keys);
|
|
267
|
+
if (!value)
|
|
268
|
+
return;
|
|
269
|
+
const parsed = new Date(value);
|
|
270
|
+
if (Number.isNaN(parsed.getTime()))
|
|
271
|
+
return;
|
|
272
|
+
return parsed.toISOString();
|
|
273
|
+
}
|
|
274
|
+
|
|
2
275
|
// src/impls/health/base-health-provider.ts
|
|
276
|
+
class HealthProviderCapabilityError extends Error {
|
|
277
|
+
code = "NOT_SUPPORTED";
|
|
278
|
+
constructor(message) {
|
|
279
|
+
super(message);
|
|
280
|
+
this.name = "HealthProviderCapabilityError";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
3
284
|
class BaseHealthProvider {
|
|
4
285
|
providerKey;
|
|
5
286
|
transport;
|
|
@@ -7,146 +288,191 @@ class BaseHealthProvider {
|
|
|
7
288
|
mcpUrl;
|
|
8
289
|
apiKey;
|
|
9
290
|
accessToken;
|
|
291
|
+
refreshToken;
|
|
10
292
|
mcpAccessToken;
|
|
11
293
|
webhookSecret;
|
|
294
|
+
webhookSignatureHeader;
|
|
295
|
+
route;
|
|
296
|
+
aggregatorKey;
|
|
297
|
+
oauth;
|
|
12
298
|
fetchFn;
|
|
13
299
|
mcpRequestId = 0;
|
|
14
300
|
constructor(options) {
|
|
15
301
|
this.providerKey = options.providerKey;
|
|
16
302
|
this.transport = options.transport;
|
|
17
|
-
this.apiBaseUrl = options.apiBaseUrl
|
|
303
|
+
this.apiBaseUrl = options.apiBaseUrl;
|
|
18
304
|
this.mcpUrl = options.mcpUrl;
|
|
19
305
|
this.apiKey = options.apiKey;
|
|
20
306
|
this.accessToken = options.accessToken;
|
|
307
|
+
this.refreshToken = options.oauth?.refreshToken;
|
|
21
308
|
this.mcpAccessToken = options.mcpAccessToken;
|
|
22
309
|
this.webhookSecret = options.webhookSecret;
|
|
310
|
+
this.webhookSignatureHeader = options.webhookSignatureHeader ?? "x-webhook-signature";
|
|
311
|
+
this.route = options.route ?? "primary";
|
|
312
|
+
this.aggregatorKey = options.aggregatorKey;
|
|
313
|
+
this.oauth = options.oauth ?? {};
|
|
23
314
|
this.fetchFn = options.fetchFn ?? fetch;
|
|
24
315
|
}
|
|
25
|
-
async listActivities(
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
activities: result.items,
|
|
29
|
-
nextCursor: result.nextCursor,
|
|
30
|
-
hasMore: result.hasMore,
|
|
31
|
-
source: this.currentSource()
|
|
32
|
-
};
|
|
316
|
+
async listActivities(_params) {
|
|
317
|
+
throw this.unsupported("activities");
|
|
33
318
|
}
|
|
34
|
-
async listWorkouts(
|
|
35
|
-
|
|
319
|
+
async listWorkouts(_params) {
|
|
320
|
+
throw this.unsupported("workouts");
|
|
321
|
+
}
|
|
322
|
+
async listSleep(_params) {
|
|
323
|
+
throw this.unsupported("sleep");
|
|
324
|
+
}
|
|
325
|
+
async listBiometrics(_params) {
|
|
326
|
+
throw this.unsupported("biometrics");
|
|
327
|
+
}
|
|
328
|
+
async listNutrition(_params) {
|
|
329
|
+
throw this.unsupported("nutrition");
|
|
330
|
+
}
|
|
331
|
+
async getConnectionStatus(params) {
|
|
332
|
+
return this.fetchConnectionStatus(params, {
|
|
333
|
+
mcpTool: `${this.providerSlug()}_connection_status`
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async syncActivities(params) {
|
|
337
|
+
return this.syncFromList(() => this.listActivities(params));
|
|
338
|
+
}
|
|
339
|
+
async syncWorkouts(params) {
|
|
340
|
+
return this.syncFromList(() => this.listWorkouts(params));
|
|
341
|
+
}
|
|
342
|
+
async syncSleep(params) {
|
|
343
|
+
return this.syncFromList(() => this.listSleep(params));
|
|
344
|
+
}
|
|
345
|
+
async syncBiometrics(params) {
|
|
346
|
+
return this.syncFromList(() => this.listBiometrics(params));
|
|
347
|
+
}
|
|
348
|
+
async syncNutrition(params) {
|
|
349
|
+
return this.syncFromList(() => this.listNutrition(params));
|
|
350
|
+
}
|
|
351
|
+
async parseWebhook(request) {
|
|
352
|
+
const payload = request.parsedBody ?? safeJsonParse(request.rawBody);
|
|
353
|
+
const verified = await this.verifyWebhook(request);
|
|
354
|
+
return toHealthWebhookEvent(payload, this.providerKey, verified);
|
|
355
|
+
}
|
|
356
|
+
async verifyWebhook(request) {
|
|
357
|
+
if (!this.webhookSecret)
|
|
358
|
+
return true;
|
|
359
|
+
const signature = readHeader(request.headers, this.webhookSignatureHeader);
|
|
360
|
+
return signature === this.webhookSecret;
|
|
361
|
+
}
|
|
362
|
+
async fetchActivities(params, config) {
|
|
363
|
+
const response = await this.fetchList(params, config);
|
|
36
364
|
return {
|
|
37
|
-
|
|
38
|
-
nextCursor:
|
|
39
|
-
hasMore:
|
|
365
|
+
activities: response.items,
|
|
366
|
+
nextCursor: response.nextCursor,
|
|
367
|
+
hasMore: response.hasMore,
|
|
40
368
|
source: this.currentSource()
|
|
41
369
|
};
|
|
42
370
|
}
|
|
43
|
-
async
|
|
44
|
-
const
|
|
371
|
+
async fetchWorkouts(params, config) {
|
|
372
|
+
const response = await this.fetchList(params, config);
|
|
45
373
|
return {
|
|
46
|
-
|
|
47
|
-
nextCursor:
|
|
48
|
-
hasMore:
|
|
374
|
+
workouts: response.items,
|
|
375
|
+
nextCursor: response.nextCursor,
|
|
376
|
+
hasMore: response.hasMore,
|
|
49
377
|
source: this.currentSource()
|
|
50
378
|
};
|
|
51
379
|
}
|
|
52
|
-
async
|
|
53
|
-
const
|
|
380
|
+
async fetchSleep(params, config) {
|
|
381
|
+
const response = await this.fetchList(params, config);
|
|
54
382
|
return {
|
|
55
|
-
|
|
56
|
-
nextCursor:
|
|
57
|
-
hasMore:
|
|
383
|
+
sleep: response.items,
|
|
384
|
+
nextCursor: response.nextCursor,
|
|
385
|
+
hasMore: response.hasMore,
|
|
58
386
|
source: this.currentSource()
|
|
59
387
|
};
|
|
60
388
|
}
|
|
61
|
-
async
|
|
62
|
-
const
|
|
389
|
+
async fetchBiometrics(params, config) {
|
|
390
|
+
const response = await this.fetchList(params, config);
|
|
63
391
|
return {
|
|
64
|
-
|
|
65
|
-
nextCursor:
|
|
66
|
-
hasMore:
|
|
392
|
+
biometrics: response.items,
|
|
393
|
+
nextCursor: response.nextCursor,
|
|
394
|
+
hasMore: response.hasMore,
|
|
67
395
|
source: this.currentSource()
|
|
68
396
|
};
|
|
69
397
|
}
|
|
70
|
-
async
|
|
71
|
-
const
|
|
72
|
-
const status = readString(payload, "status") ?? "healthy";
|
|
398
|
+
async fetchNutrition(params, config) {
|
|
399
|
+
const response = await this.fetchList(params, config);
|
|
73
400
|
return {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
source: this.currentSource()
|
|
78
|
-
lastCheckedAt: readString(payload, "lastCheckedAt") ?? new Date().toISOString(),
|
|
79
|
-
errorCode: readString(payload, "errorCode"),
|
|
80
|
-
errorMessage: readString(payload, "errorMessage"),
|
|
81
|
-
metadata: asRecord(payload.metadata)
|
|
401
|
+
nutrition: response.items,
|
|
402
|
+
nextCursor: response.nextCursor,
|
|
403
|
+
hasMore: response.hasMore,
|
|
404
|
+
source: this.currentSource()
|
|
82
405
|
};
|
|
83
406
|
}
|
|
84
|
-
async
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
async syncWorkouts(params) {
|
|
88
|
-
return this.sync("workouts", params);
|
|
89
|
-
}
|
|
90
|
-
async syncSleep(params) {
|
|
91
|
-
return this.sync("sleep", params);
|
|
407
|
+
async fetchConnectionStatus(params, config) {
|
|
408
|
+
const payload = await this.fetchPayload(config, params);
|
|
409
|
+
return toHealthConnectionStatus(payload, params, this.currentSource());
|
|
92
410
|
}
|
|
93
|
-
|
|
94
|
-
return this.sync("biometrics", params);
|
|
95
|
-
}
|
|
96
|
-
async syncNutrition(params) {
|
|
97
|
-
return this.sync("nutrition", params);
|
|
98
|
-
}
|
|
99
|
-
async parseWebhook(request) {
|
|
100
|
-
const payload = request.parsedBody ?? safeJsonParse(request.rawBody);
|
|
101
|
-
const body = asRecord(payload);
|
|
411
|
+
currentSource() {
|
|
102
412
|
return {
|
|
103
413
|
providerKey: this.providerKey,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
receivedAt: new Date().toISOString(),
|
|
108
|
-
verified: await this.verifyWebhook(request),
|
|
109
|
-
payload
|
|
414
|
+
transport: this.transport,
|
|
415
|
+
route: this.route,
|
|
416
|
+
aggregatorKey: this.aggregatorKey
|
|
110
417
|
};
|
|
111
418
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return signature === this.webhookSecret;
|
|
419
|
+
providerSlug() {
|
|
420
|
+
return this.providerKey.replace("health.", "").replace(/-/g, "_");
|
|
421
|
+
}
|
|
422
|
+
unsupported(capability) {
|
|
423
|
+
return new HealthProviderCapabilityError(`${this.providerKey} does not support ${capability}`);
|
|
118
424
|
}
|
|
119
|
-
async
|
|
120
|
-
const
|
|
121
|
-
const
|
|
425
|
+
async syncFromList(executor) {
|
|
426
|
+
const result = await executor();
|
|
427
|
+
const records = countResultRecords(result);
|
|
122
428
|
return {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
429
|
+
synced: records,
|
|
430
|
+
failed: 0,
|
|
431
|
+
nextCursor: undefined,
|
|
432
|
+
source: result.source
|
|
126
433
|
};
|
|
127
434
|
}
|
|
128
|
-
async
|
|
129
|
-
const payload = await this.
|
|
435
|
+
async fetchList(params, config) {
|
|
436
|
+
const payload = await this.fetchPayload(config, params);
|
|
437
|
+
const items = extractList(payload, config.listKeys).map((item) => config.mapItem(item, params)).filter((item) => Boolean(item));
|
|
438
|
+
const pagination = extractPagination(payload);
|
|
130
439
|
return {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
errors: asArray(payload.errors)?.map((item) => String(item)),
|
|
135
|
-
source: this.currentSource()
|
|
440
|
+
items,
|
|
441
|
+
nextCursor: pagination.nextCursor,
|
|
442
|
+
hasMore: pagination.hasMore
|
|
136
443
|
};
|
|
137
444
|
}
|
|
138
|
-
async
|
|
139
|
-
|
|
140
|
-
|
|
445
|
+
async fetchPayload(config, params) {
|
|
446
|
+
const method = config.method ?? "GET";
|
|
447
|
+
const query = config.buildQuery?.(params);
|
|
448
|
+
const body = config.buildBody?.(params);
|
|
449
|
+
if (this.isMcpTransport()) {
|
|
450
|
+
return this.callMcpTool(config.mcpTool, {
|
|
451
|
+
...query ?? {},
|
|
452
|
+
...body ?? {}
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (!config.apiPath || !this.apiBaseUrl) {
|
|
456
|
+
throw new Error(`${this.providerKey} transport is missing an API path.`);
|
|
141
457
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
458
|
+
if (method === "POST") {
|
|
459
|
+
return this.requestApi(config.apiPath, "POST", undefined, body);
|
|
460
|
+
}
|
|
461
|
+
return this.requestApi(config.apiPath, "GET", query, undefined);
|
|
462
|
+
}
|
|
463
|
+
isMcpTransport() {
|
|
464
|
+
return this.transport.endsWith("mcp") || this.transport === "unofficial";
|
|
465
|
+
}
|
|
466
|
+
async requestApi(path, method, query, body) {
|
|
467
|
+
const url = new URL(path, ensureTrailingSlash(this.apiBaseUrl ?? ""));
|
|
468
|
+
if (query) {
|
|
469
|
+
for (const [key, value] of Object.entries(query)) {
|
|
145
470
|
if (value == null)
|
|
146
471
|
continue;
|
|
147
472
|
if (Array.isArray(value)) {
|
|
148
|
-
value.forEach((
|
|
149
|
-
|
|
473
|
+
value.forEach((entry) => {
|
|
474
|
+
if (entry != null)
|
|
475
|
+
url.searchParams.append(key, String(entry));
|
|
150
476
|
});
|
|
151
477
|
continue;
|
|
152
478
|
}
|
|
@@ -155,22 +481,22 @@ class BaseHealthProvider {
|
|
|
155
481
|
}
|
|
156
482
|
const response = await this.fetchFn(url, {
|
|
157
483
|
method,
|
|
158
|
-
headers:
|
|
159
|
-
|
|
160
|
-
...this.accessToken || this.apiKey ? { Authorization: `Bearer ${this.accessToken ?? this.apiKey}` } : {}
|
|
161
|
-
},
|
|
162
|
-
body: method === "POST" ? JSON.stringify(params) : undefined
|
|
484
|
+
headers: this.authorizationHeaders(),
|
|
485
|
+
body: method === "POST" ? JSON.stringify(body ?? {}) : undefined
|
|
163
486
|
});
|
|
164
|
-
if (
|
|
165
|
-
const
|
|
166
|
-
|
|
487
|
+
if (response.status === 401 && await this.refreshAccessToken()) {
|
|
488
|
+
const retryResponse = await this.fetchFn(url, {
|
|
489
|
+
method,
|
|
490
|
+
headers: this.authorizationHeaders(),
|
|
491
|
+
body: method === "POST" ? JSON.stringify(body ?? {}) : undefined
|
|
492
|
+
});
|
|
493
|
+
return this.readResponsePayload(retryResponse, path);
|
|
167
494
|
}
|
|
168
|
-
|
|
169
|
-
return asRecord(data) ?? {};
|
|
495
|
+
return this.readResponsePayload(response, path);
|
|
170
496
|
}
|
|
171
|
-
async callMcpTool(
|
|
497
|
+
async callMcpTool(toolName, args) {
|
|
172
498
|
if (!this.mcpUrl) {
|
|
173
|
-
|
|
499
|
+
throw new Error(`${this.providerKey} MCP URL is not configured.`);
|
|
174
500
|
}
|
|
175
501
|
const response = await this.fetchFn(this.mcpUrl, {
|
|
176
502
|
method: "POST",
|
|
@@ -183,78 +509,103 @@ class BaseHealthProvider {
|
|
|
183
509
|
id: ++this.mcpRequestId,
|
|
184
510
|
method: "tools/call",
|
|
185
511
|
params: {
|
|
186
|
-
name:
|
|
187
|
-
arguments:
|
|
512
|
+
name: toolName,
|
|
513
|
+
arguments: args
|
|
188
514
|
}
|
|
189
515
|
})
|
|
190
516
|
});
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
517
|
+
const payload = await this.readResponsePayload(response, toolName);
|
|
518
|
+
const rpcEnvelope = asRecord(payload);
|
|
519
|
+
if (!rpcEnvelope)
|
|
520
|
+
return payload;
|
|
521
|
+
const rpcResult = asRecord(rpcEnvelope.result);
|
|
522
|
+
if (rpcResult) {
|
|
523
|
+
return rpcResult.structuredContent ?? rpcResult.data ?? rpcResult;
|
|
194
524
|
}
|
|
195
|
-
|
|
196
|
-
const rpc = asRecord(rpcPayload);
|
|
197
|
-
const result = asRecord(rpc?.result) ?? {};
|
|
198
|
-
const structured = asRecord(result.structuredContent);
|
|
199
|
-
if (structured)
|
|
200
|
-
return structured;
|
|
201
|
-
const data = asRecord(result.data);
|
|
202
|
-
if (data)
|
|
203
|
-
return data;
|
|
204
|
-
return result;
|
|
525
|
+
return rpcEnvelope.structuredContent ?? rpcEnvelope.data ?? rpcEnvelope;
|
|
205
526
|
}
|
|
206
|
-
|
|
527
|
+
authorizationHeaders() {
|
|
528
|
+
const token = this.accessToken ?? this.apiKey;
|
|
207
529
|
return {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
route: "primary"
|
|
530
|
+
"Content-Type": "application/json",
|
|
531
|
+
...token ? { Authorization: `Bearer ${token}` } : {}
|
|
211
532
|
};
|
|
212
533
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
534
|
+
async refreshAccessToken() {
|
|
535
|
+
if (!this.oauth.tokenUrl || !this.refreshToken) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
const tokenUrl = new URL(this.oauth.tokenUrl);
|
|
539
|
+
const body = new URLSearchParams({
|
|
540
|
+
grant_type: "refresh_token",
|
|
541
|
+
refresh_token: this.refreshToken,
|
|
542
|
+
...this.oauth.clientId ? { client_id: this.oauth.clientId } : {},
|
|
543
|
+
...this.oauth.clientSecret ? { client_secret: this.oauth.clientSecret } : {}
|
|
544
|
+
});
|
|
545
|
+
const response = await this.fetchFn(tokenUrl, {
|
|
546
|
+
method: "POST",
|
|
547
|
+
headers: {
|
|
548
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
549
|
+
},
|
|
550
|
+
body: body.toString()
|
|
551
|
+
});
|
|
552
|
+
if (!response.ok) {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
const payload = await response.json();
|
|
556
|
+
this.accessToken = payload.access_token;
|
|
557
|
+
this.refreshToken = payload.refresh_token ?? this.refreshToken;
|
|
558
|
+
if (typeof payload.expires_in === "number") {
|
|
559
|
+
this.oauth.tokenExpiresAt = new Date(Date.now() + payload.expires_in * 1000).toISOString();
|
|
560
|
+
}
|
|
561
|
+
return Boolean(this.accessToken);
|
|
562
|
+
}
|
|
563
|
+
async readResponsePayload(response, context) {
|
|
564
|
+
if (!response.ok) {
|
|
565
|
+
const message = await safeReadText(response);
|
|
566
|
+
throw new Error(`${this.providerKey} request ${context} failed (${response.status}): ${message}`);
|
|
567
|
+
}
|
|
568
|
+
if (response.status === 204) {
|
|
569
|
+
return {};
|
|
570
|
+
}
|
|
571
|
+
return response.json();
|
|
219
572
|
}
|
|
220
573
|
}
|
|
221
574
|
function readHeader(headers, key) {
|
|
222
|
-
const
|
|
223
|
-
|
|
575
|
+
const target = key.toLowerCase();
|
|
576
|
+
const entry = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === target);
|
|
577
|
+
if (!entry)
|
|
224
578
|
return;
|
|
225
|
-
const value =
|
|
579
|
+
const value = entry[1];
|
|
226
580
|
return Array.isArray(value) ? value[0] : value;
|
|
227
581
|
}
|
|
228
|
-
function
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
582
|
+
function countResultRecords(result) {
|
|
583
|
+
const listKeys = [
|
|
584
|
+
"activities",
|
|
585
|
+
"workouts",
|
|
586
|
+
"sleep",
|
|
587
|
+
"biometrics",
|
|
588
|
+
"nutrition"
|
|
589
|
+
];
|
|
590
|
+
for (const key of listKeys) {
|
|
591
|
+
const value = result[key];
|
|
592
|
+
if (Array.isArray(value)) {
|
|
593
|
+
return value.length;
|
|
594
|
+
}
|
|
239
595
|
}
|
|
240
|
-
return
|
|
596
|
+
return 0;
|
|
241
597
|
}
|
|
242
|
-
function
|
|
243
|
-
return
|
|
598
|
+
function ensureTrailingSlash(value) {
|
|
599
|
+
return value.endsWith("/") ? value : `${value}/`;
|
|
244
600
|
}
|
|
245
|
-
function
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return typeof value === "boolean" ? value : undefined;
|
|
252
|
-
}
|
|
253
|
-
function readNumber(record, key) {
|
|
254
|
-
const value = record?.[key];
|
|
255
|
-
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
601
|
+
function safeJsonParse(raw) {
|
|
602
|
+
try {
|
|
603
|
+
return JSON.parse(raw);
|
|
604
|
+
} catch {
|
|
605
|
+
return { rawBody: raw };
|
|
606
|
+
}
|
|
256
607
|
}
|
|
257
|
-
async function
|
|
608
|
+
async function safeReadText(response) {
|
|
258
609
|
try {
|
|
259
610
|
return await response.text();
|
|
260
611
|
} catch {
|
|
@@ -262,133 +613,530 @@ async function safeResponseText(response) {
|
|
|
262
613
|
}
|
|
263
614
|
}
|
|
264
615
|
|
|
265
|
-
// src/impls/health/providers.ts
|
|
266
|
-
function
|
|
616
|
+
// src/impls/health/official-health-providers.ts
|
|
617
|
+
function buildSharedQuery(params) {
|
|
267
618
|
return {
|
|
268
|
-
|
|
269
|
-
|
|
619
|
+
tenantId: params.tenantId,
|
|
620
|
+
connectionId: params.connectionId,
|
|
621
|
+
userId: params.userId,
|
|
622
|
+
from: params.from,
|
|
623
|
+
to: params.to,
|
|
624
|
+
cursor: params.cursor,
|
|
625
|
+
pageSize: params.pageSize
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function withMetricTypes(params) {
|
|
629
|
+
return {
|
|
630
|
+
...buildSharedQuery(params),
|
|
631
|
+
metricTypes: params.metricTypes
|
|
270
632
|
};
|
|
271
633
|
}
|
|
272
634
|
|
|
273
635
|
class OpenWearablesHealthProvider extends BaseHealthProvider {
|
|
636
|
+
upstreamProvider;
|
|
274
637
|
constructor(options) {
|
|
275
638
|
super({
|
|
276
|
-
providerKey: "health.openwearables",
|
|
277
|
-
|
|
639
|
+
providerKey: options.providerKey ?? "health.openwearables",
|
|
640
|
+
apiBaseUrl: options.apiBaseUrl ?? "https://api.openwearables.io",
|
|
641
|
+
webhookSignatureHeader: "x-openwearables-signature",
|
|
642
|
+
...options
|
|
643
|
+
});
|
|
644
|
+
this.upstreamProvider = options.upstreamProvider;
|
|
645
|
+
}
|
|
646
|
+
async listActivities(params) {
|
|
647
|
+
return this.fetchActivities(params, {
|
|
648
|
+
apiPath: "/v1/activities",
|
|
649
|
+
mcpTool: "openwearables_list_activities",
|
|
650
|
+
buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
|
|
651
|
+
mapItem: (item, input) => toHealthActivity(item, this.context(input), "activity")
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
async listWorkouts(params) {
|
|
655
|
+
return this.fetchWorkouts(params, {
|
|
656
|
+
apiPath: "/v1/workouts",
|
|
657
|
+
mcpTool: "openwearables_list_workouts",
|
|
658
|
+
buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
|
|
659
|
+
mapItem: (item, input) => toHealthWorkout(item, this.context(input))
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
async listSleep(params) {
|
|
663
|
+
return this.fetchSleep(params, {
|
|
664
|
+
apiPath: "/v1/sleep",
|
|
665
|
+
mcpTool: "openwearables_list_sleep",
|
|
666
|
+
buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
|
|
667
|
+
mapItem: (item, input) => toHealthSleep(item, this.context(input))
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
async listBiometrics(params) {
|
|
671
|
+
return this.fetchBiometrics(params, {
|
|
672
|
+
apiPath: "/v1/biometrics",
|
|
673
|
+
mcpTool: "openwearables_list_biometrics",
|
|
674
|
+
buildQuery: (input) => this.withUpstreamProvider(withMetricTypes(input)),
|
|
675
|
+
mapItem: (item, input) => toHealthBiometric(item, this.context(input))
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
async listNutrition(params) {
|
|
679
|
+
return this.fetchNutrition(params, {
|
|
680
|
+
apiPath: "/v1/nutrition",
|
|
681
|
+
mcpTool: "openwearables_list_nutrition",
|
|
682
|
+
buildQuery: (input) => this.withUpstreamProvider(buildSharedQuery(input)),
|
|
683
|
+
mapItem: (item, input) => toHealthNutrition(item, this.context(input))
|
|
278
684
|
});
|
|
279
685
|
}
|
|
686
|
+
async getConnectionStatus(params) {
|
|
687
|
+
return this.fetchConnectionStatus(params, {
|
|
688
|
+
apiPath: `/v1/connections/${encodeURIComponent(params.connectionId)}/status`,
|
|
689
|
+
mcpTool: "openwearables_connection_status"
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
withUpstreamProvider(query) {
|
|
693
|
+
return {
|
|
694
|
+
...query,
|
|
695
|
+
...this.upstreamProvider ? { upstreamProvider: this.upstreamProvider } : {}
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
context(params) {
|
|
699
|
+
return {
|
|
700
|
+
tenantId: params.tenantId,
|
|
701
|
+
connectionId: params.connectionId,
|
|
702
|
+
providerKey: this.providerKey
|
|
703
|
+
};
|
|
704
|
+
}
|
|
280
705
|
}
|
|
281
706
|
|
|
282
|
-
class
|
|
707
|
+
class AppleHealthBridgeProvider extends OpenWearablesHealthProvider {
|
|
283
708
|
constructor(options) {
|
|
284
709
|
super({
|
|
285
|
-
|
|
286
|
-
|
|
710
|
+
...options,
|
|
711
|
+
providerKey: "health.apple-health",
|
|
712
|
+
upstreamProvider: "apple-health"
|
|
287
713
|
});
|
|
288
714
|
}
|
|
289
715
|
}
|
|
290
716
|
|
|
291
|
-
class
|
|
717
|
+
class WhoopHealthProvider extends BaseHealthProvider {
|
|
292
718
|
constructor(options) {
|
|
293
719
|
super({
|
|
294
|
-
providerKey: "health.
|
|
295
|
-
|
|
720
|
+
providerKey: "health.whoop",
|
|
721
|
+
apiBaseUrl: options.apiBaseUrl ?? "https://api.prod.whoop.com",
|
|
722
|
+
webhookSignatureHeader: "x-whoop-signature",
|
|
723
|
+
oauth: {
|
|
724
|
+
tokenUrl: options.oauth?.tokenUrl ?? "https://api.prod.whoop.com/oauth/oauth2/token",
|
|
725
|
+
...options.oauth
|
|
726
|
+
},
|
|
727
|
+
...options
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
async listActivities(params) {
|
|
731
|
+
return this.fetchActivities(params, {
|
|
732
|
+
apiPath: "/v2/activity/workout",
|
|
733
|
+
mcpTool: "whoop_list_activities",
|
|
734
|
+
buildQuery: buildSharedQuery,
|
|
735
|
+
mapItem: (item, input) => toHealthActivity(item, this.context(input), "workout")
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
async listWorkouts(params) {
|
|
739
|
+
return this.fetchWorkouts(params, {
|
|
740
|
+
apiPath: "/v2/activity/workout",
|
|
741
|
+
mcpTool: "whoop_list_workouts",
|
|
742
|
+
buildQuery: buildSharedQuery,
|
|
743
|
+
mapItem: (item, input) => toHealthWorkout(item, this.context(input))
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
async listSleep(params) {
|
|
747
|
+
return this.fetchSleep(params, {
|
|
748
|
+
apiPath: "/v2/activity/sleep",
|
|
749
|
+
mcpTool: "whoop_list_sleep",
|
|
750
|
+
buildQuery: buildSharedQuery,
|
|
751
|
+
mapItem: (item, input) => toHealthSleep(item, this.context(input))
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
async listBiometrics(params) {
|
|
755
|
+
return this.fetchBiometrics(params, {
|
|
756
|
+
apiPath: "/v2/recovery",
|
|
757
|
+
mcpTool: "whoop_list_biometrics",
|
|
758
|
+
buildQuery: withMetricTypes,
|
|
759
|
+
mapItem: (item, input) => toHealthBiometric(item, this.context(input), "recovery_score")
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
async listNutrition(_params) {
|
|
763
|
+
throw this.unsupported("nutrition");
|
|
764
|
+
}
|
|
765
|
+
async getConnectionStatus(params) {
|
|
766
|
+
return this.fetchConnectionStatus(params, {
|
|
767
|
+
apiPath: "/v2/user/profile/basic",
|
|
768
|
+
mcpTool: "whoop_connection_status"
|
|
296
769
|
});
|
|
297
770
|
}
|
|
771
|
+
context(params) {
|
|
772
|
+
return {
|
|
773
|
+
tenantId: params.tenantId,
|
|
774
|
+
connectionId: params.connectionId,
|
|
775
|
+
providerKey: this.providerKey
|
|
776
|
+
};
|
|
777
|
+
}
|
|
298
778
|
}
|
|
299
779
|
|
|
300
780
|
class OuraHealthProvider extends BaseHealthProvider {
|
|
301
781
|
constructor(options) {
|
|
302
782
|
super({
|
|
303
783
|
providerKey: "health.oura",
|
|
304
|
-
|
|
784
|
+
apiBaseUrl: options.apiBaseUrl ?? "https://api.ouraring.com",
|
|
785
|
+
webhookSignatureHeader: "x-oura-signature",
|
|
786
|
+
oauth: {
|
|
787
|
+
tokenUrl: options.oauth?.tokenUrl ?? "https://api.ouraring.com/oauth/token",
|
|
788
|
+
...options.oauth
|
|
789
|
+
},
|
|
790
|
+
...options
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
async listActivities(params) {
|
|
794
|
+
return this.fetchActivities(params, {
|
|
795
|
+
apiPath: "/v2/usercollection/daily_activity",
|
|
796
|
+
mcpTool: "oura_list_activities",
|
|
797
|
+
buildQuery: buildSharedQuery,
|
|
798
|
+
mapItem: (item, input) => toHealthActivity(item, this.context(input))
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
async listWorkouts(params) {
|
|
802
|
+
return this.fetchWorkouts(params, {
|
|
803
|
+
apiPath: "/v2/usercollection/workout",
|
|
804
|
+
mcpTool: "oura_list_workouts",
|
|
805
|
+
buildQuery: buildSharedQuery,
|
|
806
|
+
mapItem: (item, input) => toHealthWorkout(item, this.context(input))
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
async listSleep(params) {
|
|
810
|
+
return this.fetchSleep(params, {
|
|
811
|
+
apiPath: "/v2/usercollection/sleep",
|
|
812
|
+
mcpTool: "oura_list_sleep",
|
|
813
|
+
buildQuery: buildSharedQuery,
|
|
814
|
+
mapItem: (item, input) => toHealthSleep(item, this.context(input))
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
async listBiometrics(params) {
|
|
818
|
+
return this.fetchBiometrics(params, {
|
|
819
|
+
apiPath: "/v2/usercollection/daily_readiness",
|
|
820
|
+
mcpTool: "oura_list_biometrics",
|
|
821
|
+
buildQuery: withMetricTypes,
|
|
822
|
+
mapItem: (item, input) => toHealthBiometric(item, this.context(input), "readiness_score")
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
async listNutrition(_params) {
|
|
826
|
+
throw this.unsupported("nutrition");
|
|
827
|
+
}
|
|
828
|
+
async getConnectionStatus(params) {
|
|
829
|
+
return this.fetchConnectionStatus(params, {
|
|
830
|
+
apiPath: "/v2/usercollection/personal_info",
|
|
831
|
+
mcpTool: "oura_connection_status"
|
|
305
832
|
});
|
|
306
833
|
}
|
|
834
|
+
context(params) {
|
|
835
|
+
return {
|
|
836
|
+
tenantId: params.tenantId,
|
|
837
|
+
connectionId: params.connectionId,
|
|
838
|
+
providerKey: this.providerKey
|
|
839
|
+
};
|
|
840
|
+
}
|
|
307
841
|
}
|
|
308
842
|
|
|
309
843
|
class StravaHealthProvider extends BaseHealthProvider {
|
|
310
844
|
constructor(options) {
|
|
311
845
|
super({
|
|
312
846
|
providerKey: "health.strava",
|
|
313
|
-
|
|
847
|
+
apiBaseUrl: options.apiBaseUrl ?? "https://www.strava.com",
|
|
848
|
+
webhookSignatureHeader: "x-strava-signature",
|
|
849
|
+
oauth: {
|
|
850
|
+
tokenUrl: options.oauth?.tokenUrl ?? "https://www.strava.com/oauth/token",
|
|
851
|
+
...options.oauth
|
|
852
|
+
},
|
|
853
|
+
...options
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
async listActivities(params) {
|
|
857
|
+
return this.fetchActivities(params, {
|
|
858
|
+
apiPath: "/api/v3/athlete/activities",
|
|
859
|
+
mcpTool: "strava_list_activities",
|
|
860
|
+
buildQuery: buildSharedQuery,
|
|
861
|
+
mapItem: (item, input) => toHealthActivity(item, this.context(input))
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
async listWorkouts(params) {
|
|
865
|
+
return this.fetchWorkouts(params, {
|
|
866
|
+
apiPath: "/api/v3/athlete/activities",
|
|
867
|
+
mcpTool: "strava_list_workouts",
|
|
868
|
+
buildQuery: buildSharedQuery,
|
|
869
|
+
mapItem: (item, input) => toHealthWorkout(item, this.context(input))
|
|
314
870
|
});
|
|
315
871
|
}
|
|
872
|
+
async listSleep(_params) {
|
|
873
|
+
throw this.unsupported("sleep");
|
|
874
|
+
}
|
|
875
|
+
async listBiometrics(_params) {
|
|
876
|
+
throw this.unsupported("biometrics");
|
|
877
|
+
}
|
|
878
|
+
async listNutrition(_params) {
|
|
879
|
+
throw this.unsupported("nutrition");
|
|
880
|
+
}
|
|
881
|
+
async getConnectionStatus(params) {
|
|
882
|
+
return this.fetchConnectionStatus(params, {
|
|
883
|
+
apiPath: "/api/v3/athlete",
|
|
884
|
+
mcpTool: "strava_connection_status"
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
context(params) {
|
|
888
|
+
return {
|
|
889
|
+
tenantId: params.tenantId,
|
|
890
|
+
connectionId: params.connectionId,
|
|
891
|
+
providerKey: this.providerKey
|
|
892
|
+
};
|
|
893
|
+
}
|
|
316
894
|
}
|
|
317
895
|
|
|
318
|
-
class
|
|
896
|
+
class FitbitHealthProvider extends BaseHealthProvider {
|
|
319
897
|
constructor(options) {
|
|
320
898
|
super({
|
|
321
|
-
providerKey: "health.
|
|
322
|
-
|
|
899
|
+
providerKey: "health.fitbit",
|
|
900
|
+
apiBaseUrl: options.apiBaseUrl ?? "https://api.fitbit.com",
|
|
901
|
+
webhookSignatureHeader: "x-fitbit-signature",
|
|
902
|
+
oauth: {
|
|
903
|
+
tokenUrl: options.oauth?.tokenUrl ?? "https://api.fitbit.com/oauth2/token",
|
|
904
|
+
...options.oauth
|
|
905
|
+
},
|
|
906
|
+
...options
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
async listActivities(params) {
|
|
910
|
+
return this.fetchActivities(params, {
|
|
911
|
+
apiPath: "/1/user/-/activities/list.json",
|
|
912
|
+
mcpTool: "fitbit_list_activities",
|
|
913
|
+
buildQuery: buildSharedQuery,
|
|
914
|
+
mapItem: (item, input) => toHealthActivity(item, this.context(input))
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
async listWorkouts(params) {
|
|
918
|
+
return this.fetchWorkouts(params, {
|
|
919
|
+
apiPath: "/1/user/-/activities/list.json",
|
|
920
|
+
mcpTool: "fitbit_list_workouts",
|
|
921
|
+
buildQuery: buildSharedQuery,
|
|
922
|
+
mapItem: (item, input) => toHealthWorkout(item, this.context(input))
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
async listSleep(params) {
|
|
926
|
+
return this.fetchSleep(params, {
|
|
927
|
+
apiPath: "/1.2/user/-/sleep/list.json",
|
|
928
|
+
mcpTool: "fitbit_list_sleep",
|
|
929
|
+
buildQuery: buildSharedQuery,
|
|
930
|
+
mapItem: (item, input) => toHealthSleep(item, this.context(input))
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
async listBiometrics(params) {
|
|
934
|
+
return this.fetchBiometrics(params, {
|
|
935
|
+
apiPath: "/1/user/-/body/log/weight/date/today/1m.json",
|
|
936
|
+
mcpTool: "fitbit_list_biometrics",
|
|
937
|
+
buildQuery: withMetricTypes,
|
|
938
|
+
mapItem: (item, input) => toHealthBiometric(item, this.context(input), "weight")
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
async listNutrition(params) {
|
|
942
|
+
return this.fetchNutrition(params, {
|
|
943
|
+
apiPath: "/1/user/-/foods/log/date/today.json",
|
|
944
|
+
mcpTool: "fitbit_list_nutrition",
|
|
945
|
+
buildQuery: buildSharedQuery,
|
|
946
|
+
mapItem: (item, input) => toHealthNutrition(item, this.context(input))
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
async getConnectionStatus(params) {
|
|
950
|
+
return this.fetchConnectionStatus(params, {
|
|
951
|
+
apiPath: "/1/user/-/profile.json",
|
|
952
|
+
mcpTool: "fitbit_connection_status"
|
|
323
953
|
});
|
|
324
954
|
}
|
|
955
|
+
context(params) {
|
|
956
|
+
return {
|
|
957
|
+
tenantId: params.tenantId,
|
|
958
|
+
connectionId: params.connectionId,
|
|
959
|
+
providerKey: this.providerKey
|
|
960
|
+
};
|
|
961
|
+
}
|
|
325
962
|
}
|
|
326
963
|
|
|
327
|
-
|
|
964
|
+
// src/impls/health/hybrid-health-providers.ts
|
|
965
|
+
var LIMITED_PROVIDER_SLUG = {
|
|
966
|
+
"health.garmin": "garmin",
|
|
967
|
+
"health.myfitnesspal": "myfitnesspal",
|
|
968
|
+
"health.eightsleep": "eightsleep",
|
|
969
|
+
"health.peloton": "peloton"
|
|
970
|
+
};
|
|
971
|
+
function buildSharedQuery2(params) {
|
|
972
|
+
return {
|
|
973
|
+
tenantId: params.tenantId,
|
|
974
|
+
connectionId: params.connectionId,
|
|
975
|
+
userId: params.userId,
|
|
976
|
+
from: params.from,
|
|
977
|
+
to: params.to,
|
|
978
|
+
cursor: params.cursor,
|
|
979
|
+
pageSize: params.pageSize
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
class GarminHealthProvider extends OpenWearablesHealthProvider {
|
|
328
984
|
constructor(options) {
|
|
329
985
|
super({
|
|
330
|
-
|
|
331
|
-
|
|
986
|
+
...options,
|
|
987
|
+
providerKey: "health.garmin",
|
|
988
|
+
upstreamProvider: "garmin"
|
|
332
989
|
});
|
|
333
990
|
}
|
|
334
991
|
}
|
|
335
992
|
|
|
336
|
-
class MyFitnessPalHealthProvider extends
|
|
993
|
+
class MyFitnessPalHealthProvider extends OpenWearablesHealthProvider {
|
|
337
994
|
constructor(options) {
|
|
338
995
|
super({
|
|
996
|
+
...options,
|
|
339
997
|
providerKey: "health.myfitnesspal",
|
|
340
|
-
|
|
998
|
+
upstreamProvider: "myfitnesspal"
|
|
341
999
|
});
|
|
342
1000
|
}
|
|
343
1001
|
}
|
|
344
1002
|
|
|
345
|
-
class EightSleepHealthProvider extends
|
|
1003
|
+
class EightSleepHealthProvider extends OpenWearablesHealthProvider {
|
|
346
1004
|
constructor(options) {
|
|
347
1005
|
super({
|
|
1006
|
+
...options,
|
|
348
1007
|
providerKey: "health.eightsleep",
|
|
349
|
-
|
|
1008
|
+
upstreamProvider: "eightsleep"
|
|
350
1009
|
});
|
|
351
1010
|
}
|
|
352
1011
|
}
|
|
353
1012
|
|
|
354
|
-
class PelotonHealthProvider extends
|
|
1013
|
+
class PelotonHealthProvider extends OpenWearablesHealthProvider {
|
|
355
1014
|
constructor(options) {
|
|
356
1015
|
super({
|
|
1016
|
+
...options,
|
|
357
1017
|
providerKey: "health.peloton",
|
|
358
|
-
|
|
1018
|
+
upstreamProvider: "peloton"
|
|
359
1019
|
});
|
|
360
1020
|
}
|
|
361
1021
|
}
|
|
362
1022
|
|
|
363
1023
|
class UnofficialHealthAutomationProvider extends BaseHealthProvider {
|
|
1024
|
+
providerSlugValue;
|
|
364
1025
|
constructor(options) {
|
|
365
1026
|
super({
|
|
366
|
-
...
|
|
367
|
-
providerKey: options.providerKey
|
|
1027
|
+
...options,
|
|
1028
|
+
providerKey: options.providerKey,
|
|
1029
|
+
webhookSignatureHeader: "x-unofficial-signature"
|
|
368
1030
|
});
|
|
1031
|
+
this.providerSlugValue = LIMITED_PROVIDER_SLUG[options.providerKey];
|
|
1032
|
+
}
|
|
1033
|
+
async listActivities(params) {
|
|
1034
|
+
return this.fetchActivities(params, {
|
|
1035
|
+
mcpTool: `${this.providerSlugValue}_list_activities`,
|
|
1036
|
+
buildQuery: buildSharedQuery2,
|
|
1037
|
+
mapItem: (item, input) => toHealthActivity(item, this.context(input), "activity")
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
async listWorkouts(params) {
|
|
1041
|
+
return this.fetchWorkouts(params, {
|
|
1042
|
+
mcpTool: `${this.providerSlugValue}_list_workouts`,
|
|
1043
|
+
buildQuery: buildSharedQuery2,
|
|
1044
|
+
mapItem: (item, input) => toHealthWorkout(item, this.context(input))
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
async listSleep(params) {
|
|
1048
|
+
return this.fetchSleep(params, {
|
|
1049
|
+
mcpTool: `${this.providerSlugValue}_list_sleep`,
|
|
1050
|
+
buildQuery: buildSharedQuery2,
|
|
1051
|
+
mapItem: (item, input) => toHealthSleep(item, this.context(input))
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
async listBiometrics(params) {
|
|
1055
|
+
return this.fetchBiometrics(params, {
|
|
1056
|
+
mcpTool: `${this.providerSlugValue}_list_biometrics`,
|
|
1057
|
+
buildQuery: (input) => ({
|
|
1058
|
+
...buildSharedQuery2(input),
|
|
1059
|
+
metricTypes: input.metricTypes
|
|
1060
|
+
}),
|
|
1061
|
+
mapItem: (item, input) => toHealthBiometric(item, this.context(input))
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
async listNutrition(params) {
|
|
1065
|
+
return this.fetchNutrition(params, {
|
|
1066
|
+
mcpTool: `${this.providerSlugValue}_list_nutrition`,
|
|
1067
|
+
buildQuery: buildSharedQuery2,
|
|
1068
|
+
mapItem: (item, input) => toHealthNutrition(item, this.context(input))
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
async getConnectionStatus(params) {
|
|
1072
|
+
return this.fetchConnectionStatus(params, {
|
|
1073
|
+
mcpTool: `${this.providerSlugValue}_connection_status`
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
context(params) {
|
|
1077
|
+
return {
|
|
1078
|
+
tenantId: params.tenantId,
|
|
1079
|
+
connectionId: params.connectionId,
|
|
1080
|
+
providerKey: this.providerKey
|
|
1081
|
+
};
|
|
369
1082
|
}
|
|
370
1083
|
}
|
|
371
|
-
|
|
372
1084
|
// src/impls/health-provider-factory.ts
|
|
373
1085
|
import {
|
|
374
1086
|
isUnofficialHealthProviderAllowed,
|
|
375
1087
|
resolveHealthStrategyOrder
|
|
376
1088
|
} from "@contractspec/integration.runtime/runtime";
|
|
1089
|
+
var OFFICIAL_TRANSPORT_SUPPORTED_BY_PROVIDER = {
|
|
1090
|
+
"health.openwearables": false,
|
|
1091
|
+
"health.whoop": true,
|
|
1092
|
+
"health.apple-health": false,
|
|
1093
|
+
"health.oura": true,
|
|
1094
|
+
"health.strava": true,
|
|
1095
|
+
"health.garmin": false,
|
|
1096
|
+
"health.fitbit": true,
|
|
1097
|
+
"health.myfitnesspal": false,
|
|
1098
|
+
"health.eightsleep": false,
|
|
1099
|
+
"health.peloton": false
|
|
1100
|
+
};
|
|
1101
|
+
var UNOFFICIAL_SUPPORTED_BY_PROVIDER = {
|
|
1102
|
+
"health.openwearables": false,
|
|
1103
|
+
"health.whoop": false,
|
|
1104
|
+
"health.apple-health": false,
|
|
1105
|
+
"health.oura": false,
|
|
1106
|
+
"health.strava": false,
|
|
1107
|
+
"health.garmin": true,
|
|
1108
|
+
"health.fitbit": false,
|
|
1109
|
+
"health.myfitnesspal": true,
|
|
1110
|
+
"health.eightsleep": true,
|
|
1111
|
+
"health.peloton": true
|
|
1112
|
+
};
|
|
377
1113
|
function createHealthProviderFromContext(context, secrets) {
|
|
378
1114
|
const providerKey = context.spec.meta.key;
|
|
379
1115
|
const config = toFactoryConfig(context.config);
|
|
380
1116
|
const strategyOrder = buildStrategyOrder(config);
|
|
381
|
-
const
|
|
382
|
-
for (
|
|
383
|
-
const
|
|
1117
|
+
const attemptLogs = [];
|
|
1118
|
+
for (let index = 0;index < strategyOrder.length; index += 1) {
|
|
1119
|
+
const strategy = strategyOrder[index];
|
|
1120
|
+
if (!strategy)
|
|
1121
|
+
continue;
|
|
1122
|
+
const route = index === 0 ? "primary" : "fallback";
|
|
1123
|
+
if (!supportsStrategy(providerKey, strategy)) {
|
|
1124
|
+
attemptLogs.push(`${strategy}: unsupported by ${providerKey}`);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (!hasCredentialsForStrategy(strategy, config, secrets)) {
|
|
1128
|
+
attemptLogs.push(`${strategy}: missing credentials`);
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
const provider = createHealthProviderForStrategy(providerKey, strategy, route, config, secrets);
|
|
384
1132
|
if (provider) {
|
|
385
1133
|
return provider;
|
|
386
1134
|
}
|
|
387
|
-
|
|
1135
|
+
attemptLogs.push(`${strategy}: not available`);
|
|
388
1136
|
}
|
|
389
|
-
throw new Error(`Unable to resolve health provider for ${providerKey}. Strategies attempted: ${
|
|
1137
|
+
throw new Error(`Unable to resolve health provider for ${providerKey}. Strategies attempted: ${attemptLogs.join(", ")}.`);
|
|
390
1138
|
}
|
|
391
|
-
function createHealthProviderForStrategy(providerKey, strategy, config, secrets) {
|
|
1139
|
+
function createHealthProviderForStrategy(providerKey, strategy, route, config, secrets) {
|
|
392
1140
|
const options = {
|
|
393
1141
|
transport: strategy,
|
|
394
1142
|
apiBaseUrl: config.apiBaseUrl,
|
|
@@ -396,10 +1144,21 @@ function createHealthProviderForStrategy(providerKey, strategy, config, secrets)
|
|
|
396
1144
|
apiKey: getSecretString(secrets, "apiKey"),
|
|
397
1145
|
accessToken: getSecretString(secrets, "accessToken"),
|
|
398
1146
|
mcpAccessToken: getSecretString(secrets, "mcpAccessToken"),
|
|
399
|
-
webhookSecret: getSecretString(secrets, "webhookSecret")
|
|
1147
|
+
webhookSecret: getSecretString(secrets, "webhookSecret"),
|
|
1148
|
+
route,
|
|
1149
|
+
oauth: {
|
|
1150
|
+
tokenUrl: config.oauthTokenUrl,
|
|
1151
|
+
refreshToken: getSecretString(secrets, "refreshToken"),
|
|
1152
|
+
clientId: getSecretString(secrets, "clientId"),
|
|
1153
|
+
clientSecret: getSecretString(secrets, "clientSecret"),
|
|
1154
|
+
tokenExpiresAt: getSecretString(secrets, "tokenExpiresAt")
|
|
1155
|
+
}
|
|
400
1156
|
};
|
|
401
1157
|
if (strategy === "aggregator-api" || strategy === "aggregator-mcp") {
|
|
402
|
-
return
|
|
1158
|
+
return createAggregatorProvider(providerKey, {
|
|
1159
|
+
...options,
|
|
1160
|
+
aggregatorKey: "health.openwearables"
|
|
1161
|
+
});
|
|
403
1162
|
}
|
|
404
1163
|
if (strategy === "unofficial") {
|
|
405
1164
|
if (!isUnofficialHealthProviderAllowed(providerKey, config)) {
|
|
@@ -421,6 +1180,31 @@ function createHealthProviderForStrategy(providerKey, strategy, config, secrets)
|
|
|
421
1180
|
}
|
|
422
1181
|
return createOfficialProvider(providerKey, options);
|
|
423
1182
|
}
|
|
1183
|
+
function createAggregatorProvider(providerKey, options) {
|
|
1184
|
+
if (providerKey === "health.apple-health") {
|
|
1185
|
+
return new AppleHealthBridgeProvider(options);
|
|
1186
|
+
}
|
|
1187
|
+
if (providerKey === "health.garmin") {
|
|
1188
|
+
return new GarminHealthProvider(options);
|
|
1189
|
+
}
|
|
1190
|
+
if (providerKey === "health.myfitnesspal") {
|
|
1191
|
+
return new MyFitnessPalHealthProvider(options);
|
|
1192
|
+
}
|
|
1193
|
+
if (providerKey === "health.eightsleep") {
|
|
1194
|
+
return new EightSleepHealthProvider(options);
|
|
1195
|
+
}
|
|
1196
|
+
if (providerKey === "health.peloton") {
|
|
1197
|
+
return new PelotonHealthProvider(options);
|
|
1198
|
+
}
|
|
1199
|
+
if (providerKey === "health.openwearables") {
|
|
1200
|
+
return new OpenWearablesHealthProvider(options);
|
|
1201
|
+
}
|
|
1202
|
+
return new OpenWearablesHealthProvider({
|
|
1203
|
+
...options,
|
|
1204
|
+
providerKey,
|
|
1205
|
+
upstreamProvider: providerKey.replace("health.", "")
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
424
1208
|
function createOfficialProvider(providerKey, options) {
|
|
425
1209
|
switch (providerKey) {
|
|
426
1210
|
case "health.openwearables":
|
|
@@ -438,11 +1222,20 @@ function createOfficialProvider(providerKey, options) {
|
|
|
438
1222
|
case "health.fitbit":
|
|
439
1223
|
return new FitbitHealthProvider(options);
|
|
440
1224
|
case "health.myfitnesspal":
|
|
441
|
-
return new MyFitnessPalHealthProvider(
|
|
1225
|
+
return new MyFitnessPalHealthProvider({
|
|
1226
|
+
...options,
|
|
1227
|
+
transport: "aggregator-api"
|
|
1228
|
+
});
|
|
442
1229
|
case "health.eightsleep":
|
|
443
|
-
return new EightSleepHealthProvider(
|
|
1230
|
+
return new EightSleepHealthProvider({
|
|
1231
|
+
...options,
|
|
1232
|
+
transport: "aggregator-api"
|
|
1233
|
+
});
|
|
444
1234
|
case "health.peloton":
|
|
445
|
-
return new PelotonHealthProvider(
|
|
1235
|
+
return new PelotonHealthProvider({
|
|
1236
|
+
...options,
|
|
1237
|
+
transport: "aggregator-api"
|
|
1238
|
+
});
|
|
446
1239
|
default:
|
|
447
1240
|
throw new Error(`Unsupported health provider key: ${providerKey}`);
|
|
448
1241
|
}
|
|
@@ -455,6 +1248,7 @@ function toFactoryConfig(config) {
|
|
|
455
1248
|
return {
|
|
456
1249
|
apiBaseUrl: asString(record.apiBaseUrl),
|
|
457
1250
|
mcpUrl: asString(record.mcpUrl),
|
|
1251
|
+
oauthTokenUrl: asString(record.oauthTokenUrl),
|
|
458
1252
|
defaultTransport: normalizeTransport(record.defaultTransport),
|
|
459
1253
|
strategyOrder: normalizeTransportArray(record.strategyOrder),
|
|
460
1254
|
allowUnofficial: typeof record.allowUnofficial === "boolean" ? record.allowUnofficial : false,
|
|
@@ -483,6 +1277,27 @@ function normalizeTransportArray(value) {
|
|
|
483
1277
|
const transports = value.map((item) => normalizeTransport(item)).filter((item) => Boolean(item));
|
|
484
1278
|
return transports.length > 0 ? transports : undefined;
|
|
485
1279
|
}
|
|
1280
|
+
function supportsStrategy(providerKey, strategy) {
|
|
1281
|
+
if (strategy === "official-api" || strategy === "official-mcp") {
|
|
1282
|
+
return OFFICIAL_TRANSPORT_SUPPORTED_BY_PROVIDER[providerKey];
|
|
1283
|
+
}
|
|
1284
|
+
if (strategy === "unofficial") {
|
|
1285
|
+
return UNOFFICIAL_SUPPORTED_BY_PROVIDER[providerKey];
|
|
1286
|
+
}
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
function hasCredentialsForStrategy(strategy, config, secrets) {
|
|
1290
|
+
const hasApiCredential = Boolean(getSecretString(secrets, "accessToken")) || Boolean(getSecretString(secrets, "apiKey"));
|
|
1291
|
+
const hasMcpCredential = Boolean(getSecretString(secrets, "mcpAccessToken")) || hasApiCredential;
|
|
1292
|
+
if (strategy === "official-api" || strategy === "aggregator-api") {
|
|
1293
|
+
return hasApiCredential;
|
|
1294
|
+
}
|
|
1295
|
+
if (strategy === "official-mcp" || strategy === "aggregator-mcp") {
|
|
1296
|
+
return Boolean(config.mcpUrl) && hasMcpCredential;
|
|
1297
|
+
}
|
|
1298
|
+
const hasAutomationCredential = hasMcpCredential || Boolean(getSecretString(secrets, "username")) && Boolean(getSecretString(secrets, "password"));
|
|
1299
|
+
return Boolean(config.mcpUrl) && hasAutomationCredential;
|
|
1300
|
+
}
|
|
486
1301
|
function getSecretString(secrets, key) {
|
|
487
1302
|
const value = secrets[key];
|
|
488
1303
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|