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