diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..d3b07e4 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,19 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "env": { + "node": true + }, + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended" + ], + "rules": { + "no-unused-vars": "off", + "@typescript-eslint/no-namespace": 0, + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], + "@typescript-eslint/ban-ts-comment": "off" + } +} \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..d7f8afd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "tabWidth": 3 +} diff --git a/bot/.eslintrc b/bot/.eslintrc deleted file mode 100644 index d0b8b7e..0000000 --- a/bot/.eslintrc +++ /dev/null @@ -1,41 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], - "rules": { - "max-len": ["warn", { "code": 120, "tabWidth": 2, "ignoreComments": true, "ignoreStrings": true, "ignoreTemplateLiterals": true }], - "init-declarations": ["warn", "always"], - "func-call-spacing": ["warn", "always", { "allowNewlines": true }], - "keyword-spacing": "warn", - "lines-around-comment": ["warn", { "beforeBlockComment": true }], - "lines-between-class-members": ["error", "always"], - "no-unneeded-ternary": "warn", - "object-curly-spacing": ["warn", "always"], - "semi-style": "warn", - "no-console": "error", - "space-before-blocks": "warn", - "space-in-parens": "warn", - "spaced-comment": ["warn", "always"], - "arrow-body-style": ["warn", "as-needed"], - "arrow-spacing": ["warn", { "before": true, "after": true }], - "no-var": "warn", - "semi": ["warn", "always"], - "indent": ["warn", 3, {"SwitchCase": 1}], - "prefer-const": "warn", - "key-spacing": ["warn", { "mode": "minimum" }], - "quotes": ["warn", "double"], - "array-bracket-spacing": "warn", - "block-spacing": "warn", - "brace-style": ["warn", "stroustrup", { "allowSingleLine": true }], - "no-unused-vars": "off", - "@typescript-eslint/no-namespace": 0, - "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], - "@typescript-eslint/ban-ts-comment": "off" - } -} \ No newline at end of file diff --git a/bot/tsconfig.json b/bot/tsconfig.json index e4819ce..78c0c26 100644 --- a/bot/tsconfig.json +++ b/bot/tsconfig.json @@ -10,7 +10,8 @@ "forceConsistentCasingInFileNames": true, "target": "ESNext", "outDir": "build", - "allowJs": true + "allowJs": true, + "checkJs": false }, "include": [ "src" diff --git a/package.json b/package.json index a8b2dda..4121178 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "chalk": "^5.0.1", "dotenv": "^16.0.2", "npm-run-all": "^4.1.5", + "prettier": "^3.2.2", "semver": "^7.3.7", "tsx": "^3.9.0" } diff --git a/packages/fitness/src/LoggedWorkout.js b/packages/fitness/src/LoggedWorkout.js index d35cfc8..db652b3 100644 --- a/packages/fitness/src/LoggedWorkout.js +++ b/packages/fitness/src/LoggedWorkout.js @@ -1,17 +1,3 @@ -import superagent from "superagent"; -import { EmbedBuilder } from "discord.js"; -import { - differenceInHours, - intervalToDuration, - isAfter, - subDays, -} from "date-fns"; -import { userCollection } from "./User"; - -const log = console.log.bind(console, "[fit/LoggedWorkout]"); -const defaultAvatar = - "https://discordapp.com/assets/322c936a8c8be1b803cd94861bdfa868.png"; - /** * @typedef {Object} LoggedWorkout * @prop {String} discordId @@ -23,346 +9,5 @@ const defaultAvatar = * @prop {Boolean} expFromHR */ -const expFromStreams = (maxHR, streams) => { - const hrs = streams.find((s) => s.type === "heartrate")?.data ?? []; - const ts = streams.find((s) => s.type === "time")?.data ?? []; - - if (!hrs || !ts) return null; - - const min = maxHR * 0.5; - const max = maxHR * 0.9; - - let exp = 0; - for (let i = 0; i < hrs.length; i++) { - const scale = Math.min(1, Math.max(0, (hrs[i] - min) / (max - min))); - const t = ts[i + 1] ? (ts[i + 1] - ts[i]) / 60 : 0; - exp += t * (scale * 9 + 1); - } - - return exp; -}; - -const expFromTime = (duration) => duration / 60; - -const effortScore = (xp, score = 1) => { - const needs = score * 5; - return xp > needs ? effortScore(xp - needs, score + 1) : score + xp / needs; -}; - -const workoutFromActivity = (user, activity, streams) => { - const exp = user.maxHR ? expFromStreams(user.maxHR, streams) : null; - - return { - discordId: user.discordId, - activityId: activity.id, - insertedDate: new Date().toISOString(), - name: activity.name, - exp: exp ?? expFromTime(activity.moving_time), - expFromHR: Boolean(exp), - }; -}; - -const ActivityType = { - RIDE: "Ride", - RUN: "Run", - YOGA: "Yoga", - HIKE: "Hike", - WALK: "Walk", - WORKOUT: "Workout", - CROSSFIT: "Crossfit", - VIRTUAL_RIDE: "VirtualRide", - ROCK_CLIMB: "RockClimbing", - WEIGHT_TRAIN: "WeightTraining", - GOLF: "Golf", - SWIM: "Swim", -}; - -const SportType = { - PICKLEBALL: "Pickleball", -}; - -// workout type of user is inputed -const WorkoutType = { - RUN_WORKOUT: 3, - RIDE_WORKOUT: 12, -}; - -const pad = (x) => String(x).padStart(2, "0"); - -const formatDuration = (elapsed) => { - const d = intervalToDuration({ start: 0, end: elapsed * 1000 }); - if (d.hours >= 1) return `${d.hours}:${pad(d.minutes)}:${pad(d.seconds)}`; - else if (d.minutes > 0) return `${pad(d.minutes)}:${pad(d.seconds)}`; - else return `${pad(d.seconds)} seconds`; -}; - -const formatPace = (pace) => { - const asMinutes = 26.8224 / pace; - const d = intervalToDuration({ start: 0, end: asMinutes * 60 * 1000 }); - return d.hours >= 1 - ? `${d.hours}:${pad(d.minutes)}:${pad(d.seconds)}` - : `${pad(d.minutes)}:${pad(d.seconds)}`; -}; - -// Different activities have different activity stats that are worth showing. -// We'll figure out which ones to show here, otherwise default to heartrate stats (if available) -const activityText = (activity) => { - const elapsed = formatDuration(activity.elapsed_time); - const distance = - activity.distance > 0 - ? (activity.distance * 0.000621371192).toFixed(2) + "mi" - : ""; - - const elevation = - activity.total_elevation_gain > 0 - ? (activity.total_elevation_gain * 3.2808399).toFixed(0) + "ft" - : ""; - - const pace = - activity.average_speed > 0 - ? formatPace(activity.average_speed) + "/mi" - : ""; - - const avgWatts = - activity.weighted_average_watts > 0 - ? activity.weighted_average_watts.toFixed(0) + "w" - : ""; - - switch (activity.type) { - case ActivityType.RIDE: - if (activity.workout_type === WorkoutType.RIDE_WORKOUT) { - return avgWatts - ? `did a bike workout averaging ${avgWatts} for ${elapsed}` - : `did a bike workout for ${elapsed}`; - } - if (distance) { - const msg = `rode their bike ${distance} in ${elapsed}`; - if (avgWatts) return msg + ` and averaged ${avgWatts}`; - else if (elevation) return msg + `, climbing ${elevation}`; - else return msg; - } - return `rode their bike for ${elapsed}`; - - case ActivityType.RUN: - if (activity.workout_type === WorkoutType.RUN_WORKOUT) - return `did a ${elapsed} running working`; - else if (distance && pace) - return `ran ${distance} in ${elapsed} (avg pace ${pace})`; - else if (distance) return `ran ${distance} in ${elapsed}`; - else return `ran for ${elapsed}`; - - case ActivityType.HIKE: - if (distance && elevation) - return `hiked ${distance} up ${elevation} in ${elapsed}`; - if (distance) return `hiked ${distance} in ${elapsed}`; - return `hiked for ${elapsed}`; - - case ActivityType.VIRTUAL_RIDE: - return `did a virtual ride for ${elapsed}`; - - case ActivityType.WALK: - return distance - ? `walked ${distance} in ${elapsed}` - : `walked for ${elapsed}`; - - case ActivityType.WEIGHT_TRAIN: - return `lifted weights for ${elapsed}`; - - case ActivityType.YOGA: - return `did yoga for ${elapsed}`; - - case ActivityType.CROSSFIT: - return `did crossfit for ${elapsed}`; - - case ActivityType.ROCK_CLIMB: - return `went rock climbing for ${elapsed}`; - - case ActivityType.GOLF: - return `walked ${distance} while playing golf`; - - case ActivityType.SWIM: - return `swam ${distance} for ${elapsed}`; - - case ActivityType.WORKOUT: { - switch (activity.sport_type) { - case SportType.PICKLEBALL: - return `played pickleball for ${elapsed}`; - } - } - } - return `worked out for ${elapsed}`; -}; - -const createActivityEmbed = (activity, loggedWorkout, member) => - new EmbedBuilder() - .setColor(member.displayColor) - .setAuthor({ - iconURL: member.user?.displayAvatarURL() ?? defaultAvatar, - name: `${member.displayName} ${activityText(activity)}`, - }) - .setFooter({ - text: (() => { - const hr = activity.has_heartrate - ? `hr ${activity.max_heartrate} max ${activity.average_heartrate} avg | ` - : ""; - return ( - hr + - `💦 ${effortScore(loggedWorkout.exp).toFixed(1)}` + - (!loggedWorkout.expFromHR ? "†" : "") - ); - })(), - }); - -// We won't post workouts from users who have gone inactive. -const isInactive = (user) => { - const lastActive = new Date(user.lastActive || 0); - const limit = subDays(new Date(), 14); - return isAfter(limit, lastActive); -}; - -const isTooOld = (activity) => { - const started = new Date(activity.start_date); - const age = differenceInHours(new Date(), started); - return age > 48; -}; - -// -- strava api - -const fetchToken = (refreshToken) => - superagent - .post("https://www.strava.com/oauth/token") - .send({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: process.env.STRAVA_CLIENT_ID, - client_secret: process.env.STRAVA_CLIENT_SECRET, - }) - .then((r) => r.body.access_token); - -const fetchActivity = (activityId, accessToken) => - superagent - .get(`https://www.strava.com/api/v3/activities/${activityId}`) - .auth(accessToken, { type: "bearer" }) - .then((r) => r.body); - -const fetchStreams = (activityId, accessToken) => - superagent - .get(`https://www.strava.com/api/v3/activities/${activityId}/streams`) - .query({ keys: "heartrate,time" }) - .auth(accessToken, { type: "bearer" }) - .then((r) => r.body); - /** @returns {import("mongodb").Collection} */ -const loggedWorkoutCollection = (db) => db.collection("fit-workout"); - -const postWorkout = (discord, db) => async (stravaId, activityId) => { - const user = await userCollection(db).findOne({ stravaId }); - - if (!user?.stravaId) - return new Error(`Strava ID '${stravaId}' is not authorized with the bot.`); - - if (isInactive(user)) - return new Error(`User '${user.discordId}' hasn't posted in a while`); - - const member = await discord.guilds - .fetch(process.env.SERVER_ID) - .then((x) => x.members.fetch(user.discordId)); - - if (!member) { - return new Error("User is not a member of this discord anymore"); - } - - // Fetch the details - const data = await fetchToken(user.refreshToken) - .then((token) => - Promise.all([ - fetchActivity(activityId, token), - fetchStreams(activityId, token), - ]) - ) - .catch((e) => e); - - if (data instanceof Error) { - return new Error(`Failed to fetch activity data '${activityId}'`, { - cause: data, - }); - } - - const [activity, streams] = data; - if (isTooOld(activity)) { - return new Error( - `Activity is too old. It was started ${started.toLocaleDateString()} and today is ${new Date().toLocaleDateString()}` - ); - } - - const workouts = loggedWorkoutCollection(db); - const workout = workoutFromActivity(user, activity, streams); - const embed = createActivityEmbed(activity, workout, member); - - const existing = await workouts.findOne({ activityId }); - const channel = await discord.channels.fetch(process.env.CHANNEL_STRAVA); - const message = existing?.messageId - ? await channel.messages - .fetch(existing.messageId) - .then((x) => x.edit({ embeds: [embed] })) - : await channel.send({ embeds: [embed] }); - - await loggedWorkoutCollection(db).updateOne( - { activityId }, - { $set: { ...workout, messageId: message.id } }, - { upsert: true } - ); -}; - -/** - * @param {import("discord.js").Client} discord - * @param {import("mongodb").Db} db - */ -export const stravaWebhookHandler = (discord, db) => { - // When an activity is first posted as 'created', - // We'll give the user some (n) amount of time to edit their activity - // This set just keeps track of which activities are waiting to be posted - const pendingPosts = new Set(); - const post = postWorkout(discord, db); - - return async (req) => { - const { object_type, aspect_type, object_id, owner_id } = req.payload; - - if (object_type === "athlete") { - return "Ignoring athlete update"; - } - - // Do not have a current feature to delete - // a posted workout, but it's on the TODO list - if (aspect_type === "delete") { - return "DELETE not yet supported."; - } - - if (pendingPosts.has(object_id)) { - return "Already posting this activity"; - } - - pendingPosts.add(object_id); - - const delay = - process.env.NODE_ENV === "production" && aspect_type === "create" - ? 60 * 1000 - : 0; - - log("Incoming Activity Post", req.params); - - setTimeout(() => { - post(owner_id, object_id) - .then((x) => { - if (x instanceof Error) - log(`Activity '${object_id}' did not get posted:`, x.message); - }) - .catch((err) => - log(new Error("Failed to post workout", { cause: err })) - ) - .finally(() => pendingPosts.delete(object_id)); - }, delay); - - return "Done!"; - }; -}; +export const loggedWorkoutCollection = (db) => db.collection("fit-workout"); diff --git a/packages/fitness/src/PostWorkout.js b/packages/fitness/src/PostWorkout.js new file mode 100644 index 0000000..e4012b9 --- /dev/null +++ b/packages/fitness/src/PostWorkout.js @@ -0,0 +1,364 @@ +import superagent from "superagent"; +import { EmbedBuilder } from "discord.js"; +import { + differenceInHours, + intervalToDuration, + isAfter, + subDays, +} from "date-fns"; + +import { userCollection } from "./User"; +import { loggedWorkoutCollection } from "./LoggedWorkout"; + +const log = console.log.bind(console, "[fit/PostWorkout]"); + +const defaultAvatar = + "https://discordapp.com/assets/322c936a8c8be1b803cd94861bdfa868.png"; + +const expFromStreams = (maxHR, streams) => { + const hrs = streams.find((s) => s.type === "heartrate")?.data ?? []; + const ts = streams.find((s) => s.type === "time")?.data ?? []; + + if (!hrs || !ts) return null; + + const min = maxHR * 0.5; + const max = maxHR * 0.9; + + let exp = 0; + for (let i = 0; i < hrs.length; i++) { + const scale = Math.min(1, Math.max(0, (hrs[i] - min) / (max - min))); + const t = ts[i + 1] ? (ts[i + 1] - ts[i]) / 60 : 0; + exp += t * (scale * 9 + 1); + } + + return exp; +}; + +const expFromTime = (duration) => duration / 60; + +const effortScore = (xp, score = 1) => { + const needs = score * 5; + return xp > needs ? effortScore(xp - needs, score + 1) : score + xp / needs; +}; + +const workoutFromActivity = (user, activity, streams) => { + const exp = user.maxHR ? expFromStreams(user.maxHR, streams) : null; + + return { + discordId: user.discordId, + activityId: activity.id, + insertedDate: new Date().toISOString(), + name: activity.name, + exp: exp ?? expFromTime(activity.moving_time), + expFromHR: Boolean(exp), + }; +}; + +const ActivityType = { + RIDE: "Ride", + RUN: "Run", + YOGA: "Yoga", + HIKE: "Hike", + WALK: "Walk", + WORKOUT: "Workout", + CROSSFIT: "Crossfit", + VIRTUAL_RIDE: "VirtualRide", + ROCK_CLIMB: "RockClimbing", + WEIGHT_TRAIN: "WeightTraining", + GOLF: "Golf", + SWIM: "Swim", +}; + +const SportType = { + PICKLEBALL: "Pickleball", +}; + +// workout type of user is inputed +const WorkoutType = { + RUN_WORKOUT: 3, + RIDE_WORKOUT: 12, +}; + +const pad = (x) => String(x).padStart(2, "0"); + +const formatDuration = (elapsed) => { + const d = intervalToDuration({ start: 0, end: elapsed * 1000 }); + if (d.hours >= 1) return `${d.hours}:${pad(d.minutes)}:${pad(d.seconds)}`; + else if (d.minutes > 0) return `${pad(d.minutes)}:${pad(d.seconds)}`; + else return `${pad(d.seconds)} seconds`; +}; + +const formatPace = (pace) => { + const asMinutes = 26.8224 / pace; + const d = intervalToDuration({ start: 0, end: asMinutes * 60 * 1000 }); + return d.hours >= 1 + ? `${d.hours}:${pad(d.minutes)}:${pad(d.seconds)}` + : `${pad(d.minutes)}:${pad(d.seconds)}`; +}; + +// Different activities have different activity stats that are worth showing. +// We'll figure out which ones to show here, otherwise default to heartrate stats (if available) +const activityText = (activity) => { + const elapsed = formatDuration(activity.elapsed_time); + const distance = + activity.distance > 0 + ? (activity.distance * 0.000621371192).toFixed(2) + "mi" + : ""; + + const elevation = + activity.total_elevation_gain > 0 + ? (activity.total_elevation_gain * 3.2808399).toFixed(0) + "ft" + : ""; + + const pace = + activity.average_speed > 0 + ? formatPace(activity.average_speed) + "/mi" + : ""; + + const avgWatts = + activity.weighted_average_watts > 0 + ? activity.weighted_average_watts.toFixed(0) + "w" + : ""; + + switch (activity.type) { + case ActivityType.RIDE: + if (activity.workout_type === WorkoutType.RIDE_WORKOUT) { + return avgWatts + ? `did a bike workout averaging ${avgWatts} for ${elapsed}` + : `did a bike workout for ${elapsed}`; + } + if (distance) { + const msg = `rode their bike ${distance} in ${elapsed}`; + if (avgWatts) return msg + ` and averaged ${avgWatts}`; + else if (elevation) return msg + `, climbing ${elevation}`; + else return msg; + } + return `rode their bike for ${elapsed}`; + + case ActivityType.RUN: + if (activity.workout_type === WorkoutType.RUN_WORKOUT) + return `did a ${elapsed} running working`; + else if (distance && pace) + return `ran ${distance} in ${elapsed} (avg pace ${pace})`; + else if (distance) return `ran ${distance} in ${elapsed}`; + else return `ran for ${elapsed}`; + + case ActivityType.HIKE: + if (distance && elevation) + return `hiked ${distance} up ${elevation} in ${elapsed}`; + if (distance) return `hiked ${distance} in ${elapsed}`; + return `hiked for ${elapsed}`; + + case ActivityType.VIRTUAL_RIDE: + return `did a virtual ride for ${elapsed}`; + + case ActivityType.WALK: + return distance + ? `walked ${distance} in ${elapsed}` + : `walked for ${elapsed}`; + + case ActivityType.WEIGHT_TRAIN: + return `lifted weights for ${elapsed}`; + + case ActivityType.YOGA: + return `did yoga for ${elapsed}`; + + case ActivityType.CROSSFIT: + return `did crossfit for ${elapsed}`; + + case ActivityType.ROCK_CLIMB: + return `went rock climbing for ${elapsed}`; + + case ActivityType.GOLF: + return `walked ${distance} while playing golf`; + + case ActivityType.SWIM: + return `swam ${distance} for ${elapsed}`; + + case ActivityType.WORKOUT: { + switch (activity.sport_type) { + case SportType.PICKLEBALL: + return `played pickleball for ${elapsed}`; + } + } + } + return `worked out for ${elapsed}`; +}; + +const createActivityEmbed = (activity, loggedWorkout, member) => { + const hrtext = activity.has_heartrate + ? `hr ${activity.max_heartrate} max ${activity.average_heartrate} avg | ` + : ""; + + return new EmbedBuilder({ + color: member.displayColor, + author: { + icon_url: member.user?.displayAvatarURL() ?? defaultAvatar, + name: `${member.displayName} ${activityText(activity)}`, + }, + footer: { + text: + hrtext + + `💦 ${effortScore(loggedWorkout.exp).toFixed(1)}` + + (!loggedWorkout.expFromHR ? "†" : ""), + }, + }); +}; + +// -- strava api + +const fetchToken = (refreshToken) => + superagent + .post("https://www.strava.com/oauth/token") + .send({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: process.env.STRAVA_CLIENT_ID, + client_secret: process.env.STRAVA_CLIENT_SECRET, + }) + .then((r) => r.body.access_token); + +const fetchActivity = (activityId, accessToken) => + superagent + .get(`https://www.strava.com/api/v3/activities/${activityId}`) + .auth(accessToken, { type: "bearer" }) + .then((r) => r.body); + +const fetchStreams = (activityId, accessToken) => + superagent + .get(`https://www.strava.com/api/v3/activities/${activityId}/streams`) + .query({ keys: "heartrate,time" }) + .auth(accessToken, { type: "bearer" }) + .then((r) => r.body); + +const rethrow = (reason) => (err) => { + throw new Error(reason, { cause: err }); +}; + +const assertExists = (x) => { + if (!x) { + throw new Error(`Does not exist`); + } + return x; +}; + +const assertUserIsAuthorized = (user) => { + if (!user?.stravaId) { + throw new Error( + `Strava ID '${user.stravaId}' is not authorized with the bot.`, + ); + } + return user; +}; + +const assertUserIsActive = (user) => { + const lastActive = new Date(user.lastActive || 0); + const limit = subDays(new Date(), 14); + if (isAfter(limit, lastActive)) { + throw new Error(`User '${user.discordId}' hasn't posted in a while`); + } + return user; +}; + +const assertActivityIsRecent = (activity) => { + const started = new Date(activity.start_date); + const age = differenceInHours(new Date(), started); + if (age > 48) { + throw new Error( + `Activity is too old. It was started ${started.toLocaleDateString()} and today is ${new Date().toLocaleDateString()}`, + ); + } + return activity; +}; + +const postWorkout = (discord, db) => async (stravaId, activityId) => { + const workouts = loggedWorkoutCollection(db); + const user = await userCollection(db) + .findOne({ stravaId }) + .then(assertExists) + .then(assertUserIsAuthorized) + .then(assertUserIsActive) + .catch(rethrow(`Invalid user ID`)); + + const member = await discord.guilds + .fetch(process.env.SERVER_ID) + .then((x) => x.members.fetch(user.discordId)) + .then(assertExists) + .catch(rethrow(`Could not get member`)); + + // Fetch the details + const [activity, streams] = await fetchToken(user.refreshToken) + .then((token) => + Promise.all([ + fetchActivity(activityId, token).then(assertActivityIsRecent), + fetchStreams(activityId, token), + ]), + ) + .catch(rethrow(`Failed to fetch activity data '${activityId}'`)); + + const workout = workoutFromActivity(user, activity, streams); + const embed = createActivityEmbed(activity, workout, member); + + const existing = await workouts.findOne({ activityId }); + const channel = await discord.channels.fetch(process.env.CHANNEL_STRAVA); + const message = existing?.messageId + ? await channel.messages + .fetch(existing.messageId) + .then((x) => x.edit({ embeds: [embed] })) + : await channel.send({ embeds: [embed] }); + + await loggedWorkoutCollection(db).updateOne( + { activityId }, + { $set: { ...workout, messageId: message.id } }, + { upsert: true }, + ); +}; + +/** + * @param {import("discord.js").Client} discord + * @param {import("mongodb").Db} db + */ +export const stravaWebhookHandler = (discord, db) => { + // When an activity is first posted as 'created', + // We'll give the user some (n) amount of time to edit their activity + // This set just keeps track of which activities are waiting to be posted + const pendingPosts = new Set(); + const post = postWorkout(discord, db); + + return async (req) => { + const { object_type, aspect_type, object_id, owner_id } = req.payload; + + if (object_type === "athlete") { + return "Ignoring athlete update"; + } + + // Do not have a current feature to delete + // a posted workout, but it's on the TODO list + if (aspect_type === "delete") { + return "DELETE not yet supported."; + } + + if (pendingPosts.has(object_id)) { + return "Already posting this activity"; + } + + pendingPosts.add(object_id); + + const delay = + process.env.NODE_ENV === "production" && aspect_type === "create" + ? 60 * 1000 + : 0; + + log("Incoming Activity Post", req.params); + + setTimeout(() => { + post(owner_id, object_id) + .catch((err) => + log(new Error(`Did not post '${object_id}'`, { cause: err })), + ) + .finally(() => pendingPosts.delete(object_id)); + }, delay); + + return "Done!"; + }; +}; diff --git a/packages/fitness/src/User.js b/packages/fitness/src/User.js index 521e713..f8e1f90 100644 --- a/packages/fitness/src/User.js +++ b/packages/fitness/src/User.js @@ -2,7 +2,9 @@ * @typedef {Object} UnauthorizedUser * @prop {String} discordId * @prop {String} authToken - * + */ + +/** * @typedef {Object} User * @prop {String} discordId * @prop {String} authToken diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 500ea4f..d4f0652 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: npm-run-all: specifier: ^4.1.5 version: 4.1.5 + prettier: + specifier: ^3.2.2 + version: 3.2.2 semver: specifier: ^7.3.7 version: 7.3.7 @@ -7662,6 +7665,12 @@ packages: dev: false optional: true + /prettier@3.2.2: + resolution: {integrity: sha512-HTByuKZzw7utPiDO523Tt2pLtEyK7OibUD9suEJQrPUCYQqrHr74GGX6VidMrovbf/I50mPqr8j/II6oBAuc5A==} + engines: {node: '>=14'} + hasBin: true + dev: true + /pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} diff --git a/tsconfig.json b/tsconfig.json index 52a757c..1052e29 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "outDir": "build", "baseUrl": "src", "allowJs": true, + "checkJs": false, "paths": { "@sjbha/*": ["./*"] }