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