diff --git a/README.md b/README.md index 6145c56..3385ed0 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,6 @@ The server command automatically downloads your Whoop data daily. If specified, Use a MyWhoop configuration file for more advanced configurations. For more information, refer to the [Configuration Reference](./docs/configuration_reference.md) section. -| Long Flag | Short Flag |Description | Required | Default | -|---|--|--|---|---| -| `--first-run-download` | - |Download all the available Whoop data on the first run. | No | False | - - ```bash mywhoop server ``` diff --git a/cmd/server.go b/cmd/server.go index b29300d..fcaf003 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -15,12 +15,13 @@ import ( "syscall" "time" + gocron "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" "github.com/karl-cardenas-coding/mywhoop/export" "github.com/karl-cardenas-coding/mywhoop/internal" "github.com/karl-cardenas-coding/mywhoop/notifications" "github.com/spf13/cobra" "golang.org/x/oauth2" - "golang.org/x/sync/errgroup" ) // loginCmd represents the login command @@ -33,30 +34,19 @@ var serverCmd = &cobra.Command{ }, } -var ( - // FirstRunDownload downloads all data available from the Whoop API on the first run - FirstRunDownload bool -) - func init() { - serverCmd.PersistentFlags().BoolVar(&FirstRunDownload, "first-run-download", false, "Download all data available from the Whoop API on the first run.") rootCmd.AddCommand(serverCmd) } // EvaluateConfigOptions evaluates the configuration options for the server command // Command line options take precedence over configuration file options. -func evaluateConfigOptions(firstRun bool, cfg *internal.ConfigurationData) error { +func evaluateConfigOptions(cfg *internal.ConfigurationData) error { if cfg.Export.Method == "" { slog.Info("No exporter specified. Defaulting to file.") cfg.Export.Method = "file" } - if firstRun { - slog.Info("First run download enabled") - cfg.Server.FirstRunDownload = true - } - return nil } @@ -73,10 +63,9 @@ func server(ctx context.Context) error { client := internal.CreateHTTPClient() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - var ua string = UserAgent // Evaluate the configuration options - err = evaluateConfigOptions(FirstRunDownload, &cfg) + err = evaluateConfigOptions(&cfg) if err != nil { slog.Error("unable to evaluate configuration options", "error", err) return err @@ -146,85 +135,120 @@ func server(ctx context.Context) error { } } - g, ctx := errgroup.WithContext(ctx) - - // Download the latest data for the past 24 hrs and if FirstRunDownload is enabled, all of the data. - g.Go(func() error { - - ok, _, err := internal.VerfyToken(cfg.Credentials.CredentialsFile) + sch, err := gocron.NewScheduler( + gocron.WithLocation(time.Local), + ) + if err != nil { + slog.Error("unable to create scheduler", "error", err) + return err + } + defer func() { + err = sch.Shutdown() if err != nil { - slog.Error("unable to verify authentication token", "error", err) - return err + slog.Error("unable to shutdown scheduler", "error", err) } + os.Exit(1) + }() - if !ok { - return errors.New("auth token is invalid or expired") - } + _, err = sch.NewJob( + gocron.CronJob(internal.DEFAULT_SERVER_TOKEN_REFRESH_CRON_SCHEDULE, false), + gocron.NewTask(func() error { - slog.Info("Starting data collection") + slog.Info("Refreshing auth token token") + currentToken, err := internal.ReadTokenFromFile(cfg.Credentials.CredentialsFile) + if err != nil { + return err + } - token, err := internal.ReadTokenFromFile(cfg.Credentials.CredentialsFile) - if err != nil { - slog.Error("unable to read token file", "error", err) - return err - } + auth := internal.AuthRequest{ + AuthToken: currentToken.AccessToken, + RefreshToken: currentToken.RefreshToken, + Client: client, + ClientID: os.Getenv("WHOOP_CLIENT_ID"), + ClientSecret: os.Getenv("WHOOP_CLIENT_SECRET"), + TokenURL: internal.DEFAULT_ACCESS_TOKEN_URL, + AuthorizationURL: internal.DEFAULT_AUTHENTICATION_URL, + } - var user internal.User + token, err := internal.RefreshToken(ctx, auth) + if err != nil { + return err + } - finalDataRaw, err := getData(ctx, user, client, token, &cfg.Server.FirstRunDownload, ua) - if err != nil { - slog.Error("unable to get data", "error", err) - return err - } + if len(token.AccessToken) < 1 { + return errors.New("no access token") + } - err = exportSelected.Export(finalDataRaw) - if err != nil { - slog.Error("unable to export data", "error", err) - return err - } + slog.Debug("New token generated:", token.AccessToken[0:4], "....") - err = exportSelected.CleanUp() - if err != nil { - slog.Error("unable to clean up export", "error", err) - return err - } + data, err := json.MarshalIndent(token, "", " ") + if err != nil { + return err + } - slog.Info("Data collection complete") - err = notificationMethod.Publish(client, []byte("Initial data collection complete."), internal.EventSuccess.String()) - if err != nil { - slog.Error("unable to send notification", "error", err) - } + err = os.WriteFile(cfg.Credentials.CredentialsFile, data, 0755) + if err != nil { + return err + } - return nil - - }) - // Handle a sigterm if the cron logic has not started yet - // firstSigOp := <-sigs - // if firstSigOp == syscall.SIGINT || firstSigOp == syscall.SIGTERM { - // slog.Info("program interrupt received") - // os.Exit(0) - // } - if err := g.Wait(); err != nil { - notifyErr := notificationMethod.Publish(client, []byte(fmt.Sprintf("An error occured during the initial data collection. Additional error message: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } + return nil + + }), + gocron.WithName("mywhoop_token_refresh_job"), + gocron.WithEventListeners( + gocron.AfterJobRunsWithError( + func(jobID uuid.UUID, jobName string, err error) { + slog.Error("error running token refresh job", "error", err) + notifyErr := notificationMethod.Publish(client, []byte(fmt.Sprintf("Error running the token refresh job. Additional context below: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) + } + os.Exit(1) + }, + ), + ), + ) + if err != nil { + slog.Error("unable to create token cron job", "error", err) return err } - // Start the server entry point - go func(c internal.ConfigurationData) { - err := StartServer(ctx, c, client, exportSelected, notificationMethod) - if err != nil { - slog.Error("unable to start server", "error", err) - notifyErr := notificationMethod.Publish(client, []byte(fmt.Sprintf("unable to start server. Additional error message: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } + var cronValue string + if cfg.Server.Crontab != "" { + cronValue = cfg.Server.Crontab + } else { + cronValue = internal.DEFAULT_SERVER_CRON_SCHEDULE + } + slog.Debug("Cron schedule", "schedule", cronValue) + + _, err = sch.NewJob( + gocron.CronJob(cronValue, false), + gocron.NewTask(StartServer, + ctx, + cfg, + client, + exportSelected, + notificationMethod), + gocron.WithName("mywhoop_data_collection_job"), + gocron.WithEventListeners( + gocron.AfterJobRunsWithError( + func(jobID uuid.UUID, jobName string, err error) { + slog.Error("error running server job", "error", err) + notifyErr := notificationMethod.Publish(client, []byte(fmt.Sprintf("Error running the server job. Additional context below: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) + } + os.Exit(1) + }, + ), + ), + ) + if err != nil { + slog.Error("unable to create cron job", "error", err) + return err + } - }(cfg) + sch.Start() sig := <-sigs if sig == syscall.SIGINT || sig == syscall.SIGTERM { @@ -238,6 +262,22 @@ func server(ctx context.Context) error { slog.Error("unable to send notification", "error", notifyErr) } } + err = sch.StopJobs() + if err != nil { + slog.Error("unable to stop jobs", "error", err) + notifyErr := notificationMethod.Publish(client, []byte(fmt.Sprintf("unable to stop jobs. Additional error message: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) + } + } + err = sch.Shutdown() + if err != nil { + slog.Error("unable to shutdown scheduler", "error", err) + notifyErr := notificationMethod.Publish(client, []byte(fmt.Sprintf("unable to shutdown scheduler. Additional error message: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) + } + } slog.Info("Server shutdown complete") os.Exit(0) } @@ -267,255 +307,104 @@ func StartServer(ctx context.Context, config internal.ConfigurationData, client os.Exit(1) } - authTokenChannel := make(chan oauth2.Token) - // This goroutine refreshes the token every minute. - // The token is refreshed in the background so that the server can continue to run. - go func() { - ticker := time.NewTicker(55 * time.Minute) - // ticker := time.NewTicker(2 * time.Minute) // DEBUG PURPOSES - defer ticker.Stop() - - for range ticker.C { - slog.Info("Refreshing auth token token") - currentToken, err := internal.ReadTokenFromFile(config.Credentials.CredentialsFile) - if err != nil { - slog.Error("unable to read token file", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Unable to read the authentication token from file. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } - - auth := internal.AuthRequest{ - AuthToken: currentToken.AccessToken, - RefreshToken: currentToken.RefreshToken, - Client: client, - ClientID: os.Getenv("WHOOP_CLIENT_ID"), - ClientSecret: os.Getenv("WHOOP_CLIENT_SECRET"), - TokenURL: internal.DEFAULT_ACCESS_TOKEN_URL, - AuthorizationURL: internal.DEFAULT_AUTHENTICATION_URL, - } + slog.Info("Starting data collection") + var ua string = UserAgent - token, err := internal.RefreshToken(ctx, auth) - if err != nil { - slog.Error("unable to refresh token", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Unable to refresh the authentication token. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } - authTokenChannel <- token + token, err := internal.ReadTokenFromFile(config.Credentials.CredentialsFile) + if err != nil { + slog.Error("unable to read token file", "error", err) + notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to read the authentication token from file during the regular daily retreive cycle. Additional context below: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) } - }() - - // This goroutine writes the new token to a file. - // This file is used when the Whoop API is called. - go func() { - - for auth := range authTokenChannel { - slog.Debug("New token generated:", auth.AccessToken[0:4], "....") + os.Exit(1) + } - data, err := json.MarshalIndent(auth, "", " ") - if err != nil { - slog.Error("unable to marshal token", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to marshal the authentication token value recieved from the Whoop API. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } + var user internal.User - err = os.WriteFile(config.Credentials.CredentialsFile, data, 0755) - if err != nil { - slog.Error("unable to write token file", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to write the authentication token value to the file. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } + finalDataRaw, err := getData(ctx, user, client, token, ua) + if err != nil { + slog.Error("unable to get data", "error", err) + notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to get data from the Whoop API. Additional context below: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) } - }() - - // This goroutine queries the Whoop API 24 hrs. - go func() { - - ticker := time.NewTicker(24 * time.Hour) - // ticker := time.NewTicker(1 * time.Minute) // DEBUG PURPOSES - defer ticker.Stop() - - for range ticker.C { - - slog.Info("Starting data collection") - - var ua string = UserAgent - - token, err := internal.ReadTokenFromFile(config.Credentials.CredentialsFile) - if err != nil { - slog.Error("unable to read token file", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to read the authentication token from file during the regular daily retreive cycle. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } - - var user internal.User - - finalDataRaw, err := getData(ctx, user, client, token, &config.Server.FirstRunDownload, ua) - if err != nil { - slog.Error("unable to get data", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to get data from the Whoop API. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } - - err = exp.Export(finalDataRaw) - if err != nil { - slog.Error("unable to export data", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to export data. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } - - err = exp.CleanUp() - if err != nil { - slog.Error("unable to clean up export", "error", err) - notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to clean up export. Additional context below: \n %s", err)), internal.EventErrors.String()) - if notifyErr != nil { - slog.Error("unable to send notification", "error", notifyErr) - } - os.Exit(1) - } + os.Exit(1) + } - slog.Info("Data collection complete") - err = notify.Publish(client, []byte("Daily data collection complete."), internal.EventSuccess.String()) - if err != nil { - slog.Error("unable to send notification", "error", err) - } + err = exp.Export(finalDataRaw) + if err != nil { + slog.Error("unable to export data", "error", err) + notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to export data. Additional context below: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) + } + os.Exit(1) + } + err = exp.CleanUp() + if err != nil { + slog.Error("unable to clean up export", "error", err) + notifyErr := notify.Publish(client, []byte(fmt.Sprintf("Failed to clean up export. Additional context below: \n %s", err)), internal.EventErrors.String()) + if notifyErr != nil { + slog.Error("unable to send notification", "error", notifyErr) } + os.Exit(1) + } - }() + slog.Info("Data collection complete") + err = notify.Publish(client, []byte("Daily data collection complete."), internal.EventSuccess.String()) + if err != nil { + slog.Error("unable to send notification", "error", err) + } return nil } // getData queries the Whoop API and gets the user data -func getData(ctx context.Context, user internal.User, client *http.Client, token oauth2.Token, firstDownload *bool, ua string) ([]byte, error) { +func getData(ctx context.Context, user internal.User, client *http.Client, token oauth2.Token, ua string) ([]byte, error) { - if firstDownload == nil { - slog.Debug("firstDownload is nil. Unable to determine if this is the first download") - firstDownload = new(bool) - *firstDownload = false - } + startTime, endTime := internal.GenerateLast24HoursString() + filterString := fmt.Sprintf("start=%s&end=%s", startTime, endTime) - if !*firstDownload { - startTime, endTime := internal.GenerateLast24HoursString() - filterString := fmt.Sprintf("start=%s&end=%s", startTime, endTime) + slog.Debug("Filter string", "filter", filterString) - slog.Debug("Filter string", "filter", filterString) - - sleep, err := user.GetSleepCollection(ctx, client, internal.DEFAULT_WHOOP_API_USER_SLEEP_DATA_URL, token.AccessToken, filterString, ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - sleep.NextToken = "" - user.SleepCollection = *sleep - - recovery, err := user.GetRecoveryCollection(ctx, client, internal.DEFAULT_WHOOP_API_RECOVERY_DATA_URL, token.AccessToken, filterString, ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - recovery.NextToken = "" - user.RecoveryCollection = *recovery - - workout, err := user.GetWorkoutCollection(ctx, client, internal.DEFAULT_WHOOP_API_WORKOUT_DATA_URL, token.AccessToken, filterString, ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - workout.NextToken = "" - user.WorkoutCollection = *workout - - cycle, err := user.GetCycleCollection(ctx, client, internal.DEFAULT_WHOOP_API_CYCLE_DATA_URL, token.AccessToken, filterString, ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - cycle.NextToken = "" - user.CycleCollection = *cycle + sleep, err := user.GetSleepCollection(ctx, client, internal.DEFAULT_WHOOP_API_USER_SLEEP_DATA_URL, token.AccessToken, filterString, ua) + if err != nil { + internal.LogError(err) + return []byte{}, err } - if *firstDownload { + sleep.NextToken = "" + user.SleepCollection = *sleep - data, err := user.GetUserProfileData(ctx, client, internal.DEFAULT_WHOOP_API_USER_DATA_URL, token.AccessToken, ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - user.UserData = *data - - measurements, err := user.GetUserMeasurements(ctx, client, internal.DEFAULT_WHOOP_API_USER_MEASUREMENT_DATA_URL, token.AccessToken, ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - user.UserMesaurements = *measurements - - sleep, err := user.GetSleepCollection(ctx, client, internal.DEFAULT_WHOOP_API_USER_SLEEP_DATA_URL, token.AccessToken, "", ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - sleep.NextToken = "" - user.SleepCollection = *sleep - - recovery, err := user.GetRecoveryCollection(ctx, client, internal.DEFAULT_WHOOP_API_RECOVERY_DATA_URL, token.AccessToken, "", ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } - - recovery.NextToken = "" - user.RecoveryCollection = *recovery - - workout, err := user.GetWorkoutCollection(ctx, client, internal.DEFAULT_WHOOP_API_WORKOUT_DATA_URL, token.AccessToken, "", ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } + recovery, err := user.GetRecoveryCollection(ctx, client, internal.DEFAULT_WHOOP_API_RECOVERY_DATA_URL, token.AccessToken, filterString, ua) + if err != nil { + internal.LogError(err) + return []byte{}, err + } - user.WorkoutCollection = *workout + recovery.NextToken = "" + user.RecoveryCollection = *recovery - cycle, err := user.GetCycleCollection(ctx, client, internal.DEFAULT_WHOOP_API_CYCLE_DATA_URL, token.AccessToken, "", ua) - if err != nil { - internal.LogError(err) - return []byte{}, err - } + workout, err := user.GetWorkoutCollection(ctx, client, internal.DEFAULT_WHOOP_API_WORKOUT_DATA_URL, token.AccessToken, filterString, ua) + if err != nil { + internal.LogError(err) + return []byte{}, err + } - cycle.NextToken = "" - user.CycleCollection = *cycle + workout.NextToken = "" + user.WorkoutCollection = *workout - // Set to false so that the entire data is not downloaded again - *firstDownload = false + cycle, err := user.GetCycleCollection(ctx, client, internal.DEFAULT_WHOOP_API_CYCLE_DATA_URL, token.AccessToken, filterString, ua) + if err != nil { + internal.LogError(err) + return []byte{}, err } + cycle.NextToken = "" + user.CycleCollection = *cycle + finalDataRaw, err := json.MarshalIndent(user, "", " ") if err != nil { internal.LogError(err) diff --git a/cmd/server_test.go b/cmd/server_test.go index c759abf..cabd6cf 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -20,41 +20,29 @@ func TestEvaluateConfigOptions(t *testing.T) { }, }, Server: internal.Server{ - FirstRunDownload: true, - Enabled: true, + Enabled: true, }, } - expectedFirstRunDownload := true expectedExporter := "file" - err := evaluateConfigOptions(true, dt) + err := evaluateConfigOptions(dt) if err != nil { t.Errorf("Expected nil error, got: %v", err) } - if dt.Server.FirstRunDownload != expectedFirstRunDownload { - t.Errorf("Expected %v, got: %v", expectedFirstRunDownload, dt.Server.FirstRunDownload) - } - if dt.Export.Method != expectedExporter { t.Errorf("Expected %v, got: %v", expectedExporter, dt.Export.Method) } // Second test dt.Export.Method = "" - dt.Server.FirstRunDownload = false - expectedFirstRunDownload = false - err = evaluateConfigOptions(false, dt) + err = evaluateConfigOptions(dt) if err != nil { t.Errorf("Expected nil error, got: %v", err) } - if dt.Server.FirstRunDownload != expectedFirstRunDownload { - t.Errorf("Expected %v, got: %v", expectedFirstRunDownload, dt.Server.FirstRunDownload) - } - if dt.Export.Method != expectedExporter { t.Errorf("Expected %v, got: %v", expectedExporter, dt.Export.Method) } @@ -62,16 +50,11 @@ func TestEvaluateConfigOptions(t *testing.T) { // Third test dt = &internal.ConfigurationData{} - expectedFirstRunDownload = true - err = evaluateConfigOptions(true, dt) + err = evaluateConfigOptions(dt) if err != nil { t.Errorf("Expected nil error, got: %v", err) } - if dt.Server.FirstRunDownload != expectedFirstRunDownload { - t.Errorf("Expected %v, got: %v", expectedFirstRunDownload, dt.Server.FirstRunDownload) - } - if dt.Export.Method != expectedExporter { t.Errorf("Expected %v, got: %v", expectedExporter, dt.Export.Method) } @@ -79,19 +62,14 @@ func TestEvaluateConfigOptions(t *testing.T) { // Fourth test dt = &internal.ConfigurationData{ Server: internal.Server{ - FirstRunDownload: true, + Enabled: true, }, } - expectedFirstRunDownload = true - err = evaluateConfigOptions(false, dt) + err = evaluateConfigOptions(dt) if err != nil { t.Errorf("Expected nil error, got: %v", err) } - if dt.Server.FirstRunDownload != expectedFirstRunDownload { - t.Errorf("Expected %v, got: %v", expectedFirstRunDownload, dt.Server.FirstRunDownload) - } - if dt.Export.Method != expectedExporter { t.Errorf("Expected %v, got: %v", expectedExporter, dt.Export.Method) } diff --git a/docs/configuration_reference.md b/docs/configuration_reference.md index 9fbe136..7eaae99 100644 --- a/docs/configuration_reference.md +++ b/docs/configuration_reference.md @@ -133,13 +133,13 @@ The server section of the configuration file is used to configure the server fea | Field | Description | Required | Default | |---|----|---|---| | `enabled` | Enable the server feature. | No | `false` | -| `firstRunDownload` | Download the data on the first run. This is not a recommended flag for server mode as it will re-download all the Whoop data on a server or machine reboot. | No | `false` | +| `crontab` | The crontab schedule to use for the server. By default, the server is configured to download your Whoop data daily at 1pm (13:00). Your local time zone is used. Different Operating System implement local time zone differently. Refer to the Go [time.Location](https://pkg.go.dev/time#Local) for additional details on expected behavior. | No | `0 13 * * *` | ```yaml server: enabled: true - firstRunDownload: false + crontab: "*/55 * * * *" ``` diff --git a/docs/examples/systemd/mywhoop_docker.service b/docs/examples/systemd/mywhoop_docker.service new file mode 100644 index 0000000..57499a4 --- /dev/null +++ b/docs/examples/systemd/mywhoop_docker.service @@ -0,0 +1,26 @@ +[Unit] +Description=MyWhoop +Documentation=https://github.com/karl-cardenas-coding/mywhoop +After=network.target docker.service +BindsTo=docker.service +ReloadPropagatedFrom=docker.service + +[Service] +Type=simple +ExecStartPre=-/usr/bin/docker stop mywhoop +ExecStartPre=-/usr/bin/docker rm mywhoop +ExecStartPre=/usr/bin/docker pull ghcr.io/karl-cardenas-coding/mywhoop:v1.0.0 +ExecStart=/usr/bin/docker run --name mywhoop \ + -e WHOOP_CLIENT_ID=************* \ + -e WHOOP_CLIENT_SECRET=************* \ + -e NOTIFICATION_NTFY_AUTH_TOKEN=************* \ + -e WHOOP_CREDENTIALS_FILE=/home/ubuntu/token.json \ + -v /home/ubuntu/mywhoop/:app \ + ghcr.io/karl-cardenas-coding/mywhoop:v1.0.0 +Restart=on-failure +User=ubuntu +Group=ubuntu +WorkingDirectory=/home/ubuntu/mywhoop/ + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod index 623a4c3..1ffd02d 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,11 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/jonboulle/clockwork v0.4.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect +) + require ( dario.cat/mergo v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect @@ -47,6 +52,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-co-op/gocron/v2 v2.10.0 github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -81,13 +87,13 @@ require ( go.opentelemetry.io/otel v1.24.0 // indirect go.opentelemetry.io/otel/metric v1.24.0 // indirect go.opentelemetry.io/otel/trace v1.24.0 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 // indirect - golang.org/x/mod v0.16.0 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sys v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect - golang.org/x/tools v0.19.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240304212257-790db918fca8 // indirect google.golang.org/grpc v1.62.0 // indirect google.golang.org/protobuf v1.33.0 // indirect diff --git a/go.sum b/go.sum index 0ea0e83..eceaf86 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-co-op/gocron/v2 v2.10.0 h1:qmyqSBDp1Xi59PKI+venVbwXe9N17gwup/6aXXPFJfg= +github.com/go-co-op/gocron/v2 v2.10.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -110,6 +112,8 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= +github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= @@ -146,6 +150,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -203,20 +209,20 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225 h1:LfspQV/FYTatPTr/3HzIcmiUFH7PGP+OQ6mgDYo3yuQ= -golang.org/x/exp v0.0.0-20240222234643-814bf88cf225/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -235,20 +241,20 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/const.go b/internal/const.go index 7529ae9..fd10f80 100644 --- a/internal/const.go +++ b/internal/const.go @@ -35,4 +35,8 @@ const ( DEFAULT_WHOOP_API_WORKOUT_DATA_URL = "https://api.prod.whoop.com/developer/v1/activity/workout?" // DEFAULT_WHOOP_API_CYCLE_DATA_URL is the URL to get the user cycle data from the Whoop API DEFAULT_WHOOP_API_CYCLE_DATA_URL = "https://api.prod.whoop.com/developer/v1/cycle?" + // DEFAULT_SERVER_CRON_SCHEDULE is the default cron schedule for the server. Everyday at 1:00 PM OR 1300 hours. + DEFAULT_SERVER_CRON_SCHEDULE string = "0 13 * * *" + // DEFAULT_SERVER_TOKEN_REFRESH_CRON_SCHEDULE is the default cron schedule for the token refresh. Every 55 minutes. + DEFAULT_SERVER_TOKEN_REFRESH_CRON_SCHEDULE string = "*/55 * * * *" ) diff --git a/internal/types.go b/internal/types.go index 5177793..af6db62 100644 --- a/internal/types.go +++ b/internal/types.go @@ -224,8 +224,8 @@ type NotificationConfig struct { type Server struct { // Set to true to enable server mode. Default is false. Enabled bool `yaml:"enabled"` - // Download all available Whoop data on initial server start. Default is false. - FirstRunDownload bool `yaml:"firstRunDownload"` + // A cron tab string to schedule the server to run at specific times. Default is every 24 hours at 1300 hours - 0 13 * * *. + Crontab string `yaml:"crontab"` } type Credentials struct {